morphit/docs/OPERATIONS.md
Morphit Team 7c1c3bb332
Some checks failed
morphit-ci / TypeScript typecheck (sweep all workspaces) (push) Has been cancelled
morphit-ci / apps/web svelte-check (svelte-kit sync + svelte-aware tsc) (push) Has been cancelled
morphit-ci / Integration tests (real Postgres 16) (push) Has been cancelled
morphit-ci / ansible-lint (playbook quality gate) (push) Has been cancelled
morphit-ci / Smoke suite (run-smokes.sh, triple-pulse) (push) Has been cancelled
morphit-release / Build + publish release tarball (push) Has been cancelled
v1.11.1 — registration self-heal + data-fs disk health + offline-install RPC softening + node-health unification (cp707-cp710)
2026-08-11 17:38:41 -07:00

11869 lines
530 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Morphit — operator runbook
Practical procedures for keeping Morphit running. Written for the
person who has to do something at 2am when an alert fires, not
for the person reading casually. Short and procedural by design.
If in doubt: **stop, don't improvise**. Most Morphit operations
have a "wait and think" alternative. The only irreversible actions
are key rotations and on-chain broadcasts. See the "escalation"
section for who to contact.
**⚠ This document is a template for the canonical operator.
Community operators running their own instances should adapt
paths, account names, and contact details to their own setup
before relying on any of it.**
> **First time setting up a Morphit instance?** This document is
> the reference manual, not the walkthrough. If you've never run
> a server before, start with [`RUN-A-MORPHIT-NODE.md`](./RUN-A-MORPHIT-NODE.md)
> — it's an ELI5 companion that walks you from zero to a running
> instance, with copy-paste commands and explanations of every
> step. Come back here once your instance is running and you
> need the operational reference content.
>
> **For experienced operators standing up an additional instance:**
> `scripts/vps-bootstrap.sh` (run once as root immediately after
> provisioning a fresh Ubuntu 24.04 host) bundles the host-prep
> steps from RUN-A-MORPHIT-NODE §5 — base packages, SSH hardening,
> UFW firewall, fail2ban, unattended-upgrades, unprivileged service
> users — into one idempotent script. It does NOT install Node,
> PostgreSQL, nginx, or any Morphit code; those steps remain
> manual so you can pick the versions and tunings appropriate to
> your host. Re-discovered Part 119 — the script existed but
> wasn't documented anywhere; manual-first setup is still the
> recommended path for first-time operators.
---
## Contents
0. [Initial account setup — names, roles, and tradeoffs](#0-initial-account-setup--names-roles-and-tradeoffs)
0a. [Initial account funding — the relay needs BLURT to operate](#0a-initial-account-funding--the-relay-needs-blurt-to-operate)
1. [Recurrent BLURT top-up setup (one-time)](#1-recurrent-blurt-top-up-setup-one-time)
2. [Relay account funding — weekly ACT minting REMOVED](#2-relay-account-funding--the-weekly-act-minting-ceremony-is-removed)
3. [Relay reboot](#3-relay-reboot)
4. [Responding to a witness fee change alert](#4-responding-to-a-witness-fee-change-alert)
5. [Responding to a relay-queue-stuck alert](#5-responding-to-a-relay-queue-stuck-alert)
6. [Responding to a signup velocity spike](#6-responding-to-a-signup-velocity-spike)
6a. [Moderating accounts — reviewing flags and blocking](#6a-moderating-accounts--reviewing-flags-and-blocking)
7. [Suspected relay compromise](#7-suspected-relay-compromise)
8. [Owner-key rotation ceremony](#8-owner-key-rotation-ceremony)
9. [Paper-key backup protocol](#9-paper-key-backup-protocol)
10. [Escalation](#10-escalation)
11. [Running integration tests](#11-running-integration-tests)
12. [XMR viewkey pre-deploy check (retired)](#12-xmr-viewkey-pre-deploy-check-retired)
13. [Responding to a stale BLURT/USD price feed](#13-responding-to-a-stale-blurtusd-price-feed)
14. [Deployment topology requirement — apps MUST be behind a loopback proxy](#14-deployment-topology-requirement--apps-must-be-behind-a-loopback-proxy)
15. [Frontend CSP + security headers for operators](#15-frontend-csp--security-headers-for-operators)
16. [Operator-account balance alerts](#16-operator-account-balance-alerts)
17. [Relay origin allowlist — protecting your instance from billing drift](#17-relay-origin-allowlist--protecting-your-instance-from-billing-drift)
18. [Signup-drain prevention — the full defense stack](#18-signup-drain-prevention--the-full-defense-stack)
19. [Chat anti-spam (Finding H) — operational reference](#19-chat-anti-spam-finding-h--operational-reference)
20. [Attestation phase transition (Finding I)](#20-attestation-phase-transition-finding-i)
20b. [Schema v39 upgrade note — chat read-state is re-keyed, and the indexer cannot be rolled back over it](#20b-schema-v39-upgrade-note--chat-read-state-is-re-keyed-and-the-indexer-cannot-be-rolled-back-over-it)
21. [Schema v17 upgrade note — brief orderbook sequential-scan window](#21-schema-v17-upgrade-note--brief-orderbook-sequential-scan-window)
22. [Choosing Blurt RPC endpoints](#22-choosing-blurt-rpc-endpoints)
23. [The morphit.config.env file — operator-tunable knobs in one place](#23-the-morphitconfigenv-file--operator-tunable-knobs-in-one-place)
24. [HTTP/2 deployment requirement (Phase F.5 cross-page trade events)](#24-http2-deployment-requirement-phase-f5-cross-page-trade-events)
25. [Going live — staging procedure + chain-switch](#25-going-live--staging-procedure--chain-switch)
26. [Release signing (SHA-256 + GPG)](#26-release-signing-sha-256--gpg)
27. [Fees and rewards reference](#27-fees-and-rewards-reference)
28. [Operator-earnings monitoring](#28-operator-earnings-monitoring)
29. [Running a second instance — DO NOT share relay accounts](#29-running-a-second-instance--do-not-share-relay-accounts)
30. [Postgres provisioning — the password sentinel and the init script](#30-postgres-provisioning--the-password-sentinel-and-the-init-script)
31. [Daily DB backup automation](#31-daily-db-backup-automation)
32. [BunkerWeb — recommended WAF / reverse-proxy hardening](#32-bunkerweb--recommended-waf--reverse-proxy-hardening)
33. [Docker deployment — optional alternative to bare-metal](#33-docker-deployment--optional-alternative-to-bare-metal)
34. [UFW firewall + fail2ban — extended hardening](#34-ufw-firewall--fail2ban--extended-hardening)
35. [TLS auto-renewal — quick reference](#35-tls-auto-renewal--quick-reference)
36. [Warrant canary — weekly automated regeneration](#36-warrant-canary--weekly-automated-regeneration)
37. [Comprehensive server hardening — defense-in-depth checklist](#37-comprehensive-server-hardening--defense-in-depth-checklist)
38. [Diamond-hardened squatter defense — operator playbook](#38-diamond-hardened-squatter-defense--operator-playbook)
39. [Operating a home-hosted instance — concerns specific to running on residential internet](#39-operating-a-home-hosted-instance--concerns-specific-to-running-on-residential-internet)
40. [Treasury chain-pin + XMR per-payment proofs — broadcasting and verifying](#40-treasury-chain-pin--xmr-per-payment-proofs--broadcasting-and-verifying)
41. [Federation-cost attribution — only paying for ops served by YOUR instance](#41-federation-cost-attribution--only-paying-for-ops-served-by-your-instance)
Trade-only asset configuration — enabling/disabling tradable assets [(jump)](#trade-only-asset-configuration)
Payment-method configuration — enabling/disabling canonical payment methods incl. Barter [(jump)](#payment-method-configuration)
42. [Web Push notifications — VAPID setup and the push-sender worker](#42-web-push-notifications--vapid-setup-and-the-push-sender-worker)
43. [SEO override env vars — homepage title/description/keywords + Twitter card](#43-seo-override-env-vars--homepage-titledescriptionkeywords--twitter-card)
44. [User-side optional TOTP 2FA — operator-side notes](#44-user-side-optional-totp-2fa--operator-side-notes)
45. [MCP server — AI agent surface](#45-mcp-server--ai-agent-surface)
46. [Resetting the indexer database (schema drift after an upgrade)](#46-resetting-the-indexer-database-schema-drift-after-an-upgrade)
47. [Keeping the relay funded — low-BLURT notifications](#47-keeping-the-relay-funded--low-blurt-notifications)
48. [IPFS release hosting — every instance pins the signed release](#48-ipfs-release-hosting--every-instance-pins-the-signed-release)
49. [Advanced install paths — Ansible playbook or build-from-source](#49-advanced-install-paths--ansible-playbook-or-build-from-source)
50. [How your indexer treats the public Blurt RPC nodes (User-Agent + rate limits)](#50-how-your-indexer-treats-the-public-blurt-rpc-nodes-user-agent--rate-limits)
---
## 0. Initial account setup — names, roles, and tradeoffs
Read this before registering any Blurt accounts for your
instance. The choices you make here are permanent — Blurt
account names can't be renamed, and key rotations are
involved enough that you want to get the structure right
on the first try.
### How many accounts do you actually need?
Morphit uses three configurable roles. The canonical
operator (`morphit.io`) uses three distinct Blurt accounts
for them, but that's **not required** — the code reads each
role from an independent env var, so you can collapse any or
all of them.
| Role | Env var (indexer / relay) | What it does |
|---|---|---|
| **Operator** | `MORPHIT_INDEXER_OFFICIAL_ACCOUNT_NAME` | Signs release-discovery ops. Posting-key pubkey is pinned on each instance's frontend build. |
| **Relay** | (relay's own Blurt keypair loaded at boot) | Pays the small per-op BLURT fee (from its LIQUID balance — Blurt does NOT gate on mana/RC like Hive/Steem; see docs/BLURT-CHAIN-MODEL.md) to broadcast user-signed ops on the user's behalf. Hot wallet. |
| **Fees** | `MORPHIT_INDEXER_FEE_RECIPIENT` | Receives listing-fee BLURT transfers. Cold wallet in practice — operator sweeps periodically. |
**Why the canonical operator chose three separate accounts:**
- **Operator vs relay separation** keeps the relay's hot
active key away from the operator's release-signing
posting key. If the relay VPS is compromised, the
attacker can burn BLURT on junk transactions but can't
forge release-discovery ops claiming to be from the
operator. The pinned `MORPHIT_OFFICIAL_POSTING_PUBKEY`
in `$net/config.ts` makes this a hard wall.
- **Relay vs fees separation** keeps listing-fee income
away from the hot wallet. If the relay VPS is
compromised, the attacker doesn't automatically get the
operator's accumulated fee revenue — they'd need to
compromise `@morphit-fees`'s owner key too, which is
held offline.
- **Operator vs fees separation** is the least important
but still clarifies audit trails — when users inspect
on-chain activity, `@morphit` only signs releases,
`@morphit-fees` only receives transfers. Role purity
makes "what happened and why" legible.
**Collapsing roles is defensible for small operators.**
The all-in-one variant (one account for all three) has a
much smaller blast radius if YOUR instance never grows
beyond you personally: less keys to track, one account to
fund, one to secure. The "relay + fees separate from
operator" middle variant is a common compromise — hot
wallet isolated from release-signing, but fee accumulation
in the same account that pays the per-op BLURT fee.
Pick the separation level that matches your threat model
and operational overhead tolerance. **You can always
start with one account and migrate to three later**, but
the migration means re-pinning pubkeys on all frontend
builds — coordinate with any other operators running your
config.
### Naming — the domain-prefix pattern
If you own a domain you're confident you'll hold long-term
and it fits Blurt's account-name constraints, using it as
your account prefix gives users a visible link between
your web presence and your on-chain identity. The
canonical operator did this:
- `morphit.io``@morphit`, `@morphit-relay`, `@morphit-fees`
A user seeing a `custom_json` op from `@morphit` can
immediately guess where to check what software that
op came from. If they see `@morphit-fees` as the recipient
of a fee transfer, the connection to morphit.io is
obvious without needing a directory lookup.
**Example mappings that work:**
- `peertrade.org``@peertrade`, `@peertrade-relay`, `@peertrade-fees`
- `swap.fi``@swap`, `@swap-relay`, `@swap-fees`
- `otc-corner.net``@otc-corner`, `@otc-corner-relay`
(16 chars exactly — at the limit but legal),
`@otc-corner-fees` (15 chars)
Adding role suffixes costs 6 chars (`-relay`) or 5 chars
(`-fees`) on top of your base name. A 10-char base is the
practical maximum if you want all three suffixes to fit
without abbreviation.
### Blurt account name constraints
Morphit's indexer validates account names against
`/^[a-z][a-z0-9.-]{1,14}[a-z0-9]$/`, which means:
- Length: **3 to 16 characters** total.
- First character: lowercase letter `a-z` (not a digit,
a dot, or a hyphen).
- Middle characters: lowercase `a-z`, digits `0-9`, dot
`.`, or hyphen `-`.
- Last character: lowercase `a-z` or a digit `0-9` — a
name can't end in a dot or a hyphen.
- Permanent — once registered, the name exists forever
and can't be renamed or transferred to a different
identity without an ownership change ceremony.
The regex accepts a dot or hyphen in the middle (so a
dotted name like `alice.trade` is legal), but it's a
single flat pattern — it does not enforce Blurt's full
per-segment subaccount grammar. For a top-level operator
account, plain lowercase-plus-hyphen names are the
clearest choice; stick to the constraints above and
you'll be compatible with every Morphit handler.
**Domain patterns that DON'T fit:**
- Compound TLDs (`trade.example.co.uk` — 19 chars, over
the 16-char cap).
- Domains with underscores or uppercase (not legal in
Blurt names).
- Long brand names (`community-barter-exchange.com` — the
base is 25 chars, well over the 16-char cap).
- Very short brands that can't accommodate role suffixes
(an 11-char base can fit `-fees` but not `-relay`; a
12+ char base can't fit either).
If your domain doesn't fit, pick a short memorable
alternative that you're prepared to keep indefinitely.
Treat the Blurt name like an ENS registration or a
ham-radio callsign — you're committing to it.
### The "what if I lose the domain" risk
Blurt accounts are permanent; domains are not. If you
register `@swap`/`@swap-relay`/`@swap-fees` based on
owning `swap.fi`, then let the domain lapse, a new
owner of `swap.fi` now has no legitimate connection to
your on-chain identity — but the chain still carries your
historical activity under those names, and you're stuck
with two options:
1. Keep operating under the now-misaligned names (users
searching for `swap.fi` land on someone else's site,
but your on-chain presence still says `@swap`).
2. Migrate to new account names (complicated: re-pin
pubkeys across frontend builds, coordinate with any
other operators, old accounts still show up in
historical indexer data).
**Mitigations:**
- Register the domain for the longest term your registrar
allows, and enable auto-renewal.
- Use a domain you're personally emotionally committed
to, not one you picked on a whim.
- Consider registering the same brand across multiple
TLDs to make takeover impersonation harder.
- If you're unsure about the long-term domain, use a
generic account name unconnected to any specific
domain — less shiny but robust against future rebrands.
### Setup checklist
Once you've decided on account names and separation
level:
1. Register each account via a Blurt account-creation
service or community helper. Record the owner, active,
posting, and memo keys for each in an offline backup
(paper + encrypted digital copy).
2. Fund the operator and relay accounts with enough
BLURT to cover initial per-op fees (Blurt charges a small
liquid-BLURT fee per operation, not mana/RC) — the relay especially will be
broadcasting on users' behalf.
3. Publish the operator's posting pubkey as
`MORPHIT_OFFICIAL_POSTING_PUBKEY` in your frontend
build's `$net/config.ts`. This is the release-signing
pinned key — users' clients won't trust release
announcements signed by any other key, so get this
right on the first build.
4. Configure the indexer's `MORPHIT_INDEXER_FEE_RECIPIENT`
and `MORPHIT_INDEXER_OFFICIAL_ACCOUNT_NAME` env vars
to match.
5. Proceed to §0a (initial funding) and §1 (recurrent
top-up setup), then §3 for relay reboot procedure.
---
## 0a. Initial account funding — the relay needs BLURT to operate
Your relay account needs BLURT on hand before launch.
This is the **single most common cause of failed
first-day launches** — operators assume the relay is
purely a service process and forget that it broadcasts
on-chain ops, which cost BLURT.
### Why the relay needs upfront BLURT
The relay performs four kinds of on-chain operations
that cost BLURT:
1. **Account creation for new user signups.** The
relay creates each account with a direct
`account_create` op, paying the chain's
`account_creation_fee` (currently **~100 BLURT**,
witness-set; see §4 for the change-response
runbook) **inline** from `@morphit-relay`'s liquid
balance, which the chain burns. (Blurt disabled the
Account-Creation-Token model at hard fork 2 —
ADR-0010, as amended 2026-06; there is no minting.)
**The 100 BLURT is spent at signup time, per
account.** Operator-side, the cost shows up as
"keep the relay funded with enough liquid BLURT to
cover expected signups." 100 BLURT × expected
weekly signups is the load-bearing number for
sizing.
2. **Welcome bonus + loyalty BP.** Every user who
completes their first trade and leaves feedback
receives a 20 BLURT welcome bonus (10 liquid + 10
vested) from your relay; the first verified
BLURT-paid listing fee triggers a small loyalty
BP delegation (default 1 BP, ~1 BLURT
equivalent). Per-user one-time cost — paid from
the relay's running liquid balance at the moment
the user earns it.
3. **Low-balance auto-refill (ADR-0010 §3).** When a
user runs critically low on BLURT (default threshold
0.5 BLURT) and they've been active recently, your
indexer signals the relay to top them up with a
small refill (default 1 BLURT). This keeps active
users from getting stuck mid-flow because they ran
out of chain gas. This feature is **on by default**
— disable via `MORPHIT_INDEXER_LOW_BALANCE_REFILL_INTERVAL_MS=0`
if you don't want it.
4. **Routine relay ops** — chat-identity
registrations, feedback ops, signups failure
compensations, etc. These are small (sub-BLURT) but
add up over thousands of users.
### How much to fund up front
The account-creation cost (~100 BLURT per signup,
paid inline) dominates. Size the relay's standing
liquid balance to cover roughly one week of signups —
the per-signup creation fee plus the welcome bonuses
/ refills paid from the relay's running balance:
| Use case | Approx cost breakdown | Suggested initial float |
|---|---|---|
| 1 signup | 100 BLURT fee + ~21 BLURT bonus | ~121 BLURT |
| 5 signups (quiet soft-launch with testers) | 500 BLURT fees + ~105 BLURT bonuses | **~700 BLURT** |
| 50 signups (first-week small) | 5,000 BLURT fees + ~1,050 BLURT bonuses | **~6,000 BLURT** |
| 100 signups (first-week medium) | 10,000 BLURT fees + ~2,100 BLURT bonuses | **~12,000 BLURT** |
| 100 signups + 100 low-balance refills | 10,000 BLURT fees + ~2,200 BLURT | **~12,500 BLURT** |
Operator-side this is "keep the relay funded with
enough liquid BLURT to cover next week's expected
signups plus the week's welcome bonuses + refills."
Top up via `recurrent_transfer` from a cold-key-funded
account (ADR-0010 §4) so you don't have to log in to
refill.
**Don't get caught short.** An operator who funds
just 250 BLURT (the pre-Part-112 "conservative"
figure, since corrected) cannot cover even 3 signups
at ~100 BLURT each, let alone a meaningful launch.
The old sizing-table figures (50/250/500 BLURT)
were based on a mistaken "~1 BLURT/signup" claim;
the canonical default is ~100 BLURT/signup
(`MORPHIT_INDEXER_ACCOUNT_CREATION_FEE_BLURT`
default 100, confirmed in
`apps/indexer/src/config/index.ts`).
You can top up any time without restart — the relay
checks its own balance on every signup and emits an
`operator_balance_low` log line when it's running thin.
### Where to send the BLURT
Send to whichever account name you set as
`MORPHIT_RELAY_ACCOUNT` in your relay env. The wizard
defaults this to `@morphit-relay`, but if you used a
different name (e.g. `@your-org-relay`), use that.
You can confirm your configured relay account at any
time with:
```bash
grep MORPHIT_RELAY_ACCOUNT /etc/morphit/relay.env
```
> **Running the on-chain `morphit-ops` commands on a systemd deploy:**
> `payment-method`, `register`, and `show-key` read
> `MORPHIT_RELAY_ACCOUNT` (and the operator key-file path) straight from
> `morphit.env`. On a systemd install that file is sourced only by the
> unit — not by your interactive shell — and is usually root-owned, so
> run these with **sudo**. They are tagged `(needs sudo)` in the menu,
> and `morphit-ops` now auto-loads `morphit.env` + `morphit.config.env`
> from the install root when you run them, so `sudo morphit-ops
> payment-method …` works without manually sourcing the env first.
### Funding the @morphit account (~10 BLURT, small fixed cost)
The `@morphit` account is the **trust-anchor account**
that signs the canonical `morphit_release_v1` op, which
pins BTC + XMR treasury addresses on chain. Posting key
stays on the operator's personal laptop, OFF the
morphit.io production box — only the signed serialized
op is copied to a place where the broadcast can happen.
The **warrant canary** is a separate primitive — a
PGP-signed file served at `/canary.txt` (from the
frontend's `build/` dir), signed with the operator's
PGP key. **Sign it on your OWN machine (your laptop),
not the server, and upload the signed file to the
server's `apps/web/build/`. A server-side cron is a
FLAW:** a seized box would keep auto-signing "all-clear"
canaries forever, silently turning the canary into a
lie. Off-server signing makes it go stale exactly when
it should — if you're compelled and gagged, if something
happens to you, or if the box is seized without your key.
The canary does NOT consume `@morphit` BLURT; it lives
off-chain and uses a PGP keypair, not the Blurt posting key.
**Setting it up (guided).** `scripts/canary/setup.sh`
walks you through the whole thing once and then keeps the
canary fresh on a weekly timer, so it is not an ongoing
chore. Run it from the machine you want to sign on; it
asks which of two deployments you have:
- **Home hosting (local)** — Morphit runs on this same
machine. The canary is signed here and copied straight
into the served `build/` dir. Understand the trade-off:
signing on the served box means a seizure of that box
could forge future canaries. For the strongest canary,
sign from a separate machine (the remote mode below).
- **Remote server (VPS)** — Morphit runs elsewhere. You
sign on your admin machine (key OFF the server) and the
script uploads the signed file to the server's
`apps/web/build/`. This is the recommended arrangement.
If you have no PGP key yet the script offers to create one
and publishes the matching public key to
`apps/web/static/pgp_keys.asc` (served at `/pgp_keys.asc`,
which readers verify the signature against). After setup,
the weekly refresh lives at `~/.morphit/update-canary.sh`.
**After every `morphit-ops upgrade`** the rebuild wipes the
served `build/` dir, so re-run the refresh once to restore
the canary — the upgrade prints this reminder:
```
bash ~/.morphit/update-canary.sh
```
That rebuild runs as root (`sudo morphit-ops`), so vite
recreates `apps/web/build` **root-owned** — but a remote
(VPS) canary uploads `canary.txt` + `pgp_keys.asc` into that
dir over SSH as your non-root app user. To keep the refresh
from failing with `Permission denied` after each upgrade,
`morphit-ops upgrade` now restores the dir's ownership once
the build finishes: it re-applies whatever non-root owner the
dir already had (falling back to the install-dir owner), so
the served dir stays writable for the upload. If your very
first setup on a fresh box hits `Permission denied` (the
initial build left `build/` root-owned before any upgrade ran
the restore), fix it once with
`sudo chown -R <your-ssh-user> /opt/morphit/apps/web/build`
and it persists across future upgrades from then on.
**Offline upgrade (cable unplugged).** A node must never be
forced online to upgrade. On a box with no internet, download
the self-contained `morphit-<ver>-offline.tar.gz` **and its
`.asc` signature** on another machine, copy both over, and run
`sudo morphit-ops upgrade --from-file=/path/to/morphit-<ver>-offline.tar.gz`
(or set `MORPHIT_UPGRADE_TARBALL` to that path — handy for a
scripted/cron offline upgrade). This skips all network
discovery and download: the tarball's signature is verified
against the release-signer keys shipped in the install
(`.forgejo/release-signers/*.asc`), and an **unsigned** tarball
is **refused** — with no reachable primary there is no anchored
hash to fall back on, so a GPG signature is mandatory (the same
"standalone-mirror-safe" trust rule the online path uses when
the primary is unreachable). The bundle's prebuilt
`node_modules` (marked `.morphit-bundle-complete`) means the
rebuild skips `npm ci`, so it reaches no registry either — the
entire upgrade completes with the network cable unplugged, just
like the offline first install.
**Offline drop-dir + auto-fallback.** Instead of passing
`--from-file` each time, drop the signed tarball (+ its `.asc`)
into the offline release dir — `<installDir>-offline` (e.g.
`/opt/morphit-offline`) by default, overridable with
`MORPHIT_OFFLINE_RELEASE_DIR`. The main menu then shows
**● update available (offline tarball ready)** on its own (the
version is read from the filename), and a normal `morphit-ops
upgrade` FALLS BACK to that signed tarball automatically whenever
every network source is unreachable — so an upgrade begun online
still finishes offline if the link drops mid-run. An unsigned
tarball in the dir is ignored (offline installs require a
signature).
**Machine-readable health (`morphit-ops health --json`).** The
health view is also emitted as JSON for monitoring:
`morphit-ops health --json` prints one object with `indexer`,
`relay`, `system` (cpu/mem/disk), `services`, `backups`,
`canary`, and `ipfs_seeding` (the IPFS pin + IPNS-rebroadcast
state — `ok`/`degraded`/`down`/`not-configured`). Point Zabbix
at it — run it on a timer into a file, or via an agent — and
alert on any section leaving its healthy state. Host-level
operational detail is deliberately kept OUT of the public
`/v1/health` (a public federation probe should not learn your
backups are failing or your disk is full); use `--json` locally
(or over SSH) for the full picture.
**Which disk the `system.disk_*` figure measures.** It reports
the filesystem holding your node's DATA — the unbounded grower
(Postgres / the chain index) — not always `/`. The guided
install sets `MORPHIT_HEALTH_DISK_PATH` to the Postgres data
directory's parent, so `statfs` resolves it to whichever mount
holds the DB. On a single-volume box that's the same as `/`; on
a **split-volume** node (a dedicated data mount) it tracks the
volume that actually fills, instead of cheerfully reporting a
comfortable `/`. If your data lives elsewhere, set an absolute
path:
```
# /etc/morphit/indexer.env
MORPHIT_HEALTH_DISK_PATH=/mnt/morphit-data
```
A bad value safely falls back to `/`, so the figure never blanks.
**Third-party resilience.** The freshness proofs embedded
in every canary — the Blurt and Bitcoin chain heads and a
news headline — are fetched with wide failover so a single
provider outage can't stall the weekly refresh. The Blurt
head rotates across the canonical RPC list; the Bitcoin
head hops across five independent explorers (Blockstream,
mempool.space, Blockchain.com, Blockchair, BlockCypher);
the news line falls through six independent feeds. The
Bitcoin head and news line are SECONDARY to the Blurt head,
so even if every one of their providers is unreachable the
canary still signs (recording them as unavailable) instead
of dying.
Pre-fund `@morphit` with **~10 BLURT** before launch.
This is a small fixed cost, not signup-rate-dependent:
- Initial `morphit_release_v1` broadcast — sub-BLURT.
- Subsequent re-pins (rare: only when treasury
addresses rotate or the frontend hash manifest
changes) — sub-BLURT each.
Rounding up generously to ~10 BLURT covers many years
of releases with comfortable headroom. Most operators
won't need to top this up for years.
### Funding the fees account (and whether it needs BLURT)
The `@morphit-fees` account (or whatever you set as
`MORPHIT_INDEXER_FEE_RECIPIENT`) **receives** BLURT-paid
listing fees and **has no signing key on any
production box** — it's genuinely receive-only. No
upfront BLURT funding required.
As a federated operator you **earn 90% of the BLURT
listing fees** paid on your instance (the remaining 10%
goes to the canonical project treasury), so this is where
your revenue lands — set it to an account you control.
The split happens **at payment time**: each user's fee
transaction pays 90% straight into this account and 10%
to the canonical `@morphit-fees`, in the same transaction,
so your share arrives directly within one Blurt block — no
forwarding step, nobody to trust to remit it later. You
enter the account during `morphit-ops init` (step "Fees
account") and can change it any time with `morphit-ops
edit`**Fees account** (or by editing
`MORPHIT_INDEXER_FEE_RECIPIENT` directly and restarting
the indexer).
**Fallback / safety.** If you leave
`MORPHIT_INDEXER_FEE_RECIPIENT` empty or set it to a
malformed Blurt account name, the indexer does **not**
crash — it logs a `fee_recipient_invalid` warning at boot
and falls back to the canonical `@morphit-fees` treasury.
The frontend reads the resolved account from
`/v1/instance`, so it always pays fees to exactly the
account your indexer verifies against (they can never
drift apart). If you see fees you expected arriving at
`@morphit-fees` instead of your own account, check that
boot warning — your configured name was rejected.
**However:** the account must exist on chain before any
listing fees can land in it. Default
`@morphit-fees` already exists on canonical morphit.io.
If you set a custom fees account name, ensure that
account is created on Blurt before your first listing
fee tries to deliver there — otherwise the user's
fee transfer fails, the order doesn't promote to
`verified`, and you have a confused user.
### Quick reference: all three Morphit accounts
| Account | Role | Upfront funding | Signing key location |
|---|---|---|---|
| `@morphit` | Trust anchor (release pin) | ~10 BLURT | Operator's laptop (OFF prod) |
| `@morphit-relay` | Service account (account-creation fees + bonuses + refills + payouts) | ~700 BLURT (testers) ~12,000 BLURT (100 signups/week) | Encrypted on prod box at `/etc/morphit/keys/relay-active.key` mode 0400 |
| `@morphit-fees` | Receive-only treasury | ~0 BLURT | Not on any production box |
The `@morphit-relay` figure dominates because of the
~100 BLURT chain account-creation fee the relay pays
inline per signup. Plan ~100 BLURT per expected
signup plus a safety margin.
### Long-term funding — see §1
§0a is about getting enough BLURT in the relay
account to launch. §1 covers the recurrent-transfer
mechanism for keeping the relay topped up over time
without manual intervention.
### Monitoring the relay balance
The `/v1/health?verbose=1` endpoint exposes the
relay's last-known balance under
`diagnostics.operator_balances[] | select(.role == "relay")
| .last_observed_blurt` (the scanner reports one entry per
configured operator account; `relay` and `fees` are the
two default roles). Add this to your monitoring; alert
when it falls below your refill threshold, or when the
matching entry's `below_threshold` flag goes true. See
`docs/POST-LAUNCH-WEEK-ONE.md` for sample monitoring
scripts.
> **Verbose mode requires an env opt-in.** Every
> `/v1/health?verbose=1` reference in the operator docs
> assumes you've set `MORPHIT_INDEXER_VERBOSE_HEALTH=true`
> in `ops/env/indexer.env`. Without it, `?verbose=1`
> returns the same minimal `{ok}` payload as the plain
> endpoint — by design (audit finding NEW-9-8: verbose
> mode is operator-opt-in to keep attackers from timing
> drain attempts against unhardened instances). Surfaced
> Part 119 (Sally-operator finding So-3).
>
> **Exception — per-source price-feed status is automatic.**
> `morphit-ops health` shows each price provider, whether
> it answered this cycle, and the price it reported, with
> no env opt-in. It travels in a top-level `price_feeds`
> block the indexer returns only when the request carries
> `X-Morphit-Local-Health: 1` — which the local ops-cli
> sends over the bridge and the public edge strips
> (`proxy_set_header X-Morphit-Local-Health "";`), so it's
> operator-only without exposing which of your feeds are
> down to a public caller. `MORPHIT_INDEXER_VERBOSE_HEALTH`
> still gates the fuller diagnostics (operator balances,
> explorer pools, drift/disagreement) — the parts NEW-9-8
> protects.
### Monitoring the price-manipulation defenses (drift / disagreement / peer)
The `morphit_native` pricing stack ships three independent
price-manipulation defenses (ADR-0039 defenses B, C, F; ADR-0041).
As of cp233 all three surface in the verbose price block of
`/v1/health`, and each also log-alerts on a sustained breach, so you
can wire them into monitoring:
```
curl -s "http://127.0.0.1:8081/v1/health?verbose=1" \
| jq '.diagnostics.price'
```
> **If your indexer does not bind loopback:** `morphit-ops health`
> tries `127.0.0.1:8081` first and then auto-probes your host's own
> bridge-gateway addresses (docker0 / br-*), so a container deployment
> that binds the bridge gateway (e.g. `172.18.0.1`) so its frontend
> container can reach it is found automatically — no flag needed. If
> it still can't reach it (an unusual bind address), pass
> `--url http://<host>:8081/v1/health` or set `MORPHIT_OPS_HEALTH_URL`.
> This read-only view never needs sudo.
When the optional price feed is enabled, `diagnostics.price` carries
`enabled`, `blurt_usd`, `source`, `updated_at`, `stale`, plus three
defense blocks (each `null` until it has data):
- **`drift` — Defense B (slow-drift).** `baseline` is the time-decayed
moving baseline; `deviation` is the signed fraction the current
price sits from it; `above_threshold` / `sustained_hours` / `alert`
track a sustained divergence. `alert: true` means the published
price has been walking away from its own baseline for longer than
the sustained window — the "frog in boiling water" attack the
per-cycle smoothing cap can't see. Peer-independent (works on a
lone instance).
- **`disagreement` — Defense C (native vs external).** Compares the
published external market price against the self-sovereign
`morphit_native` price. `active` / `deviation` / `sustained_hours`
/ `alert` track a sustained divergence between the two. A `null`
`external_price` means there was no external market price to compare
this cycle (e.g. external sources briefly unreachable) — by design C
stays quiet rather than comparing against the static floor (which
would false-alarm). Also peer-independent.
- **`peer` — Defense F (cross-instance).** Only meaningful once your
instance is federated with ≥3 reachable peers and
`MORPHIT_INDEXER_PEER_PRICE_MONITOR_ENABLED=true`.
`peers_queried` / `peer_median` / `my_price` / `deviation` /
`above_threshold` / `alert` compare your derived price against the
federation median.
**What to do on an alert.** None of these auto-correct the price (that
would be its own attack vector) — they make manipulation loud. On a
sustained `alert: true`, treat the listing-fee USD echo as suspect
until you've investigated: check whether an external source is feeding
a bad number (`price.source`, `price.stale`), whether your on-chain
trade data looks manipulated (the
`/v1/price/morphit-native/receipt` endpoint shows the full
derivation), and — for `peer` — whether your instance is the outlier
or the federation is. The same alerts appear in the indexer logs
(`price_drift_alert`, `price_source_disagreement`, and the peer
sample-cycle warnings).
### Monitoring RPC endpoint health
Both the indexer and the relay depend on a pool of public
Blurt RPC endpoints. When **all** of them stop responding
(a DNS change, an upstream outage, a rate-limit wall), the
indexer stops advancing and the relay can't broadcast — but
the process keeps running, so the failure is easy to
misread. `/v1/health` makes this visible without verbose
mode:
```
curl -s http://127.0.0.1:8081/v1/health \
| jq '{rpc_endpoints_healthy, rpc_endpoints_total, lag_blocks}'
```
`rpc_endpoints_healthy` is how many endpoints are currently
reachable (out of cooldown), out of `rpc_endpoints_total`.
**If this reads `0` while the node is behind, RPC — not the
indexer — is the problem;** check your
`MORPHIT_INDEXER_RPC_ENDPOINTS` (indexer) /
`MORPHIT_RELAY_BLURT_RPC` (relay) list. A non-zero count
with growing `lag_blocks` is normal during the initial
back-fill.
> **Friendlier: `morphit-ops health`.** Rather than curling and
> reading JSON, `morphit-ops health` (menu: "Check & operate" →
> "Node health — indexer, relay, system, services, canary") gives one
> consolidated view of the whole node:
> - **Indexer** and **Relay** — each hits its own `/v1/health` and
> prints synced/behind (with the lag in blocks) or unreachable,
> plus version, uptime, and the healthy/total RPC count. Both
> auto-probe the bridge gateway if loopback doesn't answer (see the
> note above). The relay is reported as optional.
> - **System** — CPU, memory, and root-disk usage of the box itself,
> read locally (unprivileged: `os` counters, `/proc/meminfo`, and a
> `statfs` on `/` — no config or DB access, so it works regardless of
> file permissions, same as the rest of this view). Memory and disk
> show used/total in GB with a percentage (disk uses the `df`-style
> used-of-usable figure so it matches `df -h /`); a line reads
> `unavailable` rather than failing if a metric can't be read. Useful
> for spotting a saturated CPU or a filling disk — often the real
> reason an indexer starts lagging.
> - **Services** — the read-only `systemctl` active-state of
> `morphit-matrix-bot` and `morphit-mcp`. (The MCP runs the hardened
> Streamable-HTTP transport bound to `127.0.0.1:8124`, so besides the
> systemd service state you can curl its liveness directly:
> `curl http://127.0.0.1:8124/health` → `{"status":"ok","transport":"http"}`.)
> - **Canary** — whether `apps/web/build/canary.txt` is current
> (parsed from its `Valid through:` line) or overdue for its weekly
> regeneration. Since v1.3.5 the generator writes the sitewide human
> format — `22 July, 2026 @ 23:45:18 UTC` — and health reads BOTH that
> and the older Zulu ISO form (`2026-07-22T23:45:18Z`), so a canary you
> signed before upgrading keeps verifying. Both are parsed strictly: a
> misspelled month or a timestamp with no timezone reports `unparsable`
> rather than being guessed at, because a canary's freshness is the
> whole security signal and a silently-skewed one is worse than an
> obviously broken one.
>
> It resolves the indexer URL from `--url`, then `MORPHIT_OPS_HEALTH_URL`,
> then `--host`/`--port` (or the indexer's own
> `MORPHIT_INDEXER_LISTEN_HOST`/`_PORT`), defaulting to
> `http://127.0.0.1:8081/v1/health`. It needs no config or DB, so it
> works as the unprivileged `morphit` user even when the `status`
> dashboard would EACCES. Exit code is `0` synced / `1`
> reachable-but-behind / `2` indexer-unreachable, so it drops into a
> cron health-check.
>
> The service and canary checks by hand (also read-only, no sudo):
>
> ```
> systemctl is-active morphit-matrix-bot # or morphit-mcp, morphit-relay
> grep '^Valid through:' apps/web/build/canary.txt # compare to today's date
> ```
For deep triage, verbose mode adds per-endpoint detail:
```
curl -s "http://127.0.0.1:8081/v1/health?verbose=1" \
| jq '.diagnostics.rpc_endpoints'
# [{ url, state: open|half_open|closed, consecutive_failures,
# cooldown_remaining_ms, ewma_latency_ms, last_success_age_s }]
```
### Catch-up fetches blocks in batches (v1.7.5)
The poller does **not** spend one HTTP request per block while
catching up. It prefetches a 20-block window with a single
JSON-RPC 2.0 batch, then still applies those blocks one-per-
database-transaction. A 5,000-block backlog is therefore 250
requests at the RPC node, not 5,000.
This matters because catch-up — not steady-state — is what gets
an instance rate-limited. At the chain head an indexer asks for
about one block every 3 s and no node notices. After downtime it
asks for thousands as fast as the pacer allows, and Morphit is
federated, so the nodes see that burst from every instance at
once. Batching is the second of the four things the
`rpc.blurt.blog` operator asked us for (lower RPS, **batch**,
exponential backoff, jitter); the other three landed in v1.7.0.
Batch support is **discovered, not assumed**. A node that
answers a batch with a single object rather than an array — or
one whose edge firewall rejects the batch array outright with a
4xx (many public Blurt nodes return HTTP 406 or 403 to a JSON-RPC
`[...]` POST while happily serving single calls; handled since
v1.8.1) — is recorded for the lifetime of the process and quietly
served by the old one-block-at-a-time path from then on. Nothing
to configure, and nothing breaks on a node that can't (or won't)
batch: a single strict node can no longer stall the poller, so the
default six-node set works as-is with no manual endpoint-picking.
(Before v1.8.1 a batch 4xx was a fatal, non-rotatable error, so one
firewalled node leading the pool could freeze catch-up entirely —
`lag_blocks` climbing with a repeating `HTTP 406 (batch get_block)`
in the log. If you see that on an older build, pinning
`MORPHIT_INDEXER_RPC_ENDPOINTS` to a node that serves batches is the
interim workaround; upgrading is the fix.) If you want to confirm
your node is getting the benefit, watch the request rate against a
node during a catch-up: batching is working if it is roughly a
twentieth of the block rate.
`state` is `open` while an endpoint is in cooldown after
transport failures, `half_open` just after cooldown expires
(eligible to retry, not yet proven), `closed` when healthy.
A rate-limited (`HTTP 429`) or overloaded (`HTTP 502/503/504`)
response counts as a transport failure too, so a throttled
endpoint shows `open`, the node rotates away from it, and the
cooldown ladder backs off automatically instead of hammering
it — which is what the relay needed during the firefight.
A single dead endpoint is harmless — the pool rotates to a
healthy one within the same call; the count and the `state`
fields exist so you can spot a *degrading* endpoint before
it takes the whole list down. (Endpoint URLs appear only in
the verbose block, which is env-opt-in per the note above.)
> dblurt's own internal "Didn't failover for error code:
> [...]" console line is **suppressed** — that pool does the
> real failover for us, and this `/v1/health` view is the
> authoritative signal. If you still see that line, you're
> on a pre-beta5 build.
**Catch dead endpoints before they bite.** You don't have
to wait for a stalled sync to discover an endpoint is gone:
- `morphit-ops doctor` probes every configured endpoint
(`MORPHIT_INDEXER_RPC_ENDPOINTS` + `MORPHIT_RELAY_BLURT_RPC`)
with a real `get_dynamic_global_properties` call and reports
which are reachable, as part of its normal run. Pass
`--no-rpc` for a purely-local check (no network). The RPC
result is advisory — it does not change doctor's
boot-readiness exit code (a node still *starts* with dead
endpoints; it just can't sync), but an `All N endpoints
unreachable` line is your cue to fix the list.
- `morphit-ops init` probes the list you enter during setup
and warns (offering to edit) if any endpoint doesn't
respond — so a typo or a decommissioned node is caught at
config time, not at 3 a.m.
**Sensible defaults, shared by both services.** If you leave
`MORPHIT_INDEXER_RPC_ENDPOINTS` and `MORPHIT_RELAY_BLURT_RPC`
unset, the indexer and relay both fall back to the *same*
vetted set of independent public nodes — so a fresh node can
never end up with one service pointed at working endpoints
and the other pointed at nothing (an asymmetry that bit a
real operator). The wizard also writes that same set to both
explicitly. The list is defined in one place
(`@morphit/operator-config`); to change it project-wide, edit
that constant — adding more independent nodes increases your
resilience against the simultaneous rate-limiting that public
RPC nodes occasionally hit under load.
When the relay runs out of BLURT, signups silently
start failing (the user sees a generic error in the
UI; the underlying cause is logged as
`account_creation_failed_insufficient_balance` in
the relay's structured logs). This is recoverable
— top up the account and signups resume — but
visible to users in the interim.
### Public network-stats endpoint (`/v1/stats`)
Every node automatically serves a small, **aggregate-only** JSON summary at
`https://<your-instance>/v1/stats` — active/total orders, completed-trade
counts (lifetime + last 30 days), the tradable assets you support, and how
many assets/fiat currencies currently have live orders. It is **public, needs
no configuration**, and is rate-limited like the other read endpoints. It is
also linked from the site footer ("Stats").
The endpoint is intended for third-party P2P aggregators (RoboSats, Bisq,
Hodl Hodl, AgoraDesk peers, …) evaluating your instance for a marketplace
listing — point them there. By design it exposes **nothing per-account**: only
coarse counts and static config, so it can't be used to profile or correlate
individual traders. (Instance *configuration* — disabled assets/payment
methods, treasury addresses, fees — lives on `/v1/instance`.) There is
deliberately no trade-*volume* figure yet: order amounts span many fiat
currencies and aren't meaningfully summable without USD normalization, so the
endpoint reports counts rather than a misleading notional total.
---
## 1. Recurrent BLURT top-up setup (one-time)
Per ADR-0010 §4, `@morphit-relay` is refilled on a weekly cadence
from a funding account via Blurt's native `recurrent_transfer`
operation. Set this up once. It runs autonomously until the
configured duration expires.
### Prerequisites
- A funding account on Blurt that is NOT `@morphit-relay` and
NOT `@morphit-fees`. This account holds the weekly disbursement
pool and its active key lives on paper, not on any server.
- An accurate estimate of your weekly signup rate. Start
conservative — 20 signups/week × (100 BLURT fee + 20 BLURT
welcome + overhead) ≈ 2,500 BLURT/week.
- A period during which you plan to run Morphit. The
`recurrent_transfer` op takes a `recurrence` in hours and an
`executions` count. Reasonable defaults: `recurrence=168`
(weekly), `executions=52` (one year).
### Procedure
1. On an air-gapped machine, load the funding account's active
key. Use `dblurt` or any Blurt-compatible signer.
2. Build and sign a `recurrent_transfer` op:
```json
[
"recurrent_transfer",
{
"from": "<funding-account>",
"to": "morphit-relay",
"amount": "2500.000 BLURT",
"memo": "morphit:weekly-topup",
"recurrence": 168,
"executions": 52,
"extensions": []
}
]
```
3. Broadcast the signed tx from the air-gapped machine to a
Blurt RPC node (transfer the raw hex over sneakernet or QR
code; never plug the air-gapped machine into a network).
4. Immediately **power down the air-gapped machine** and lock
the funding account's active-key paper back in the safe.
5. Note the transaction ID in your operator journal along with
the amount and end date. Set a calendar reminder for 11 months
ahead to renew.
### Verifying the transfer is live
On any Blurt RPC, call `condenser_api.find_recurrent_transfers`
with the funding account name. You should see the new
recurrent_transfer with `remaining_executions` counting down
weekly.
### Stopping the recurrent transfer early
Broadcast another `recurrent_transfer` op with the same `from`
and `to` and `amount = "0.000 BLURT"`. Blurt interprets a zero
recurrent_transfer as a cancellation.
---
## 2. Relay account funding — the weekly ACT minting ceremony is REMOVED
> **This ceremony no longer exists (beta.28).** Blurt disabled
> `claim_account` / `create_claimed_account` at hard fork 2, so there are
> no Account Creation Tokens to mint. The relay now creates each account
> with a direct `account_create` op, paying the ~100 BLURT
> `account_creation_fee` **inline** from its liquid BLURT (ADR-0010, as
> amended 2026-06). There is no `mint-acts.ts` script, no weekly timer,
> and no `MORPHIT_RELAY_AUTOMINT_*` config — if you are upgrading from an
> older deploy, remove the `morphit-relay-mint-acts.service`/`.timer`
> units and any `MORPHIT_RELAY_AUTOMINT_*` / `MORPHIT_RELAY_WEEKLY_ACT_COUNT`
> lines from `relay.env`.
>
> **What this means for you as an operator:** keep `@morphit-relay`
> funded with enough liquid BLURT to cover signups — roughly one week of
> (≈100 BLURT creation fee + 2 BLURT dust + the welcome bonus per
> completed-trade signup). See **§0a** for the funding math and **§16**
> for the low-balance Matrix alert (`relay_low_balance_for_signups`). The
> relay refuses signups (`relay_out_of_funds`) when its liquid balance
> can't cover the fee + a small margin, and recovers automatically the
> moment you top it up.
---
## 3. Relay reboot
The relay's active key is held in process memory only (ADR-0010
§4). The key file on disk is an encrypted envelope; its passphrase
is supplied at boot by a systemd **encrypted credential**, so after
a reboot (planned or unplanned) the relay unlocks the key and
restarts **unattended** — no operator needs to type anything. (See
the Prerequisites below for the one-time credential setup; the same
steps appear in RUN-A-MORPHIT-NODE.md "Set up systemd services" for
a fresh install.)
### In-memory key handling (2026-05-07 audit)
For operators reasoning about the threat model: when the relay
decrypts its active-key envelope at boot, the scrypt-derived KDF
key and the intermediate plaintext Buffer are **explicitly zeroed
in a `finally` block** after use (see
`apps/relay/src/crypto/keyEnvelope.ts:decryptEnvelope`). The
`key-envelope-smoke.ts` runner enforces this discipline against
regressions — both the encrypt and decrypt paths must contain
`finally { key.fill(0); ... }` or the smoke fails.
**Residual surface (honest disclosure):** the decrypted WIF is
returned as a JS string and then stored in the in-memory
`UnlockedConfig`. JS strings are immutable and cannot be zeroed;
they live until V8 garbage collection or process exit. This is
intentional for the relay's persistent-signer role — it needs to
sign account_create ops continuously. An attacker who achieves
process-memory read access on the relay host can extract the WIF;
this is the threat the file-system permissions (0400 owner-only)
+ §37 server hardening + the encrypted-credential key unlock are
designed to defend against. There is no JS-level mitigation for
the immutable-string residue beyond the existing host-level
defenses.
**No operator action required for this audit.** The hardening
fixes are entirely in-process; existing key files, envelopes, and
passphrases continue to work unchanged. The guarantees you had
before this audit are still in place; the audit added defensive
zeroing for previously-unzeroed intermediate key material in the
KDF derivation step.
### Prerequisites (one-time setup)
The key file must be an encrypted envelope produced by
`apps/relay/scripts/encrypt-active-key.ts`. If you're still running with
a plaintext WIF file (dev / legacy), migrate now:
```sh
cd /opt/morphit/apps/relay
tsx apps/relay/scripts/encrypt-active-key.ts \
/etc/morphit/keys/relay-active.key \
/etc/morphit/keys/relay-active.enc
```
You'll be prompted for the passphrase twice. Choose something
you'll remember — there is no reset. Store it in your password
manager.
Then update the relay's env to point at the new file:
```sh
# /etc/morphit/relay.env
MORPHIT_RELAY_ACTIVE_KEY_FILE=/etc/morphit/keys/relay-active.enc
```
Then create the systemd **encrypted credential** that unlocks the
envelope at boot — this is what the shipped `morphit-relay.service`
requires (it will not start without it):
```sh
echo -n 'your-passphrase' | sudo systemd-creds encrypt \
--name=relay_passphrase - /etc/morphit/relay_passphrase.cred
sudo chmod 0600 /etc/morphit/relay_passphrase.cred
```
systemd-creds binds the credential to this host's key (and the TPM,
if present): it auto-decrypts on every start, so the relay comes up
unattended, and the file is useless if copied off the machine. The
relay unit's `LoadCredentialEncrypted=relay_passphrase:…` hands the
decrypted value to the process as a tmpfs file via
`MORPHIT_RELAY_ACTIVE_KEY_PASSPHRASE_FILE` — the passphrase never
touches persistent disk in cleartext and never appears in the
process environment.
Verify by restarting the relay (`sudo systemctl restart
morphit-relay`) and confirming it reaches `active (running)` and
serves `/v1/health` with no prompt. Once it unlocks successfully,
securely destroy any remaining plaintext key:
```sh
sudo shred -u /etc/morphit/keys/relay-active.key
```
### Planned reboot
1. Notify the operator-on-call channel before rebooting.
2. `sudo systemctl stop morphit-relay.service` — this pauses
the queue drainer. In-flight welcome bonuses finish; the
queue stays on disk.
3. Reboot.
4. The enabled `morphit-relay.service` starts **automatically**
on boot: systemd decrypts the credential, the relay unlocks
its key into memory, and normal operation resumes — nothing
to type. (If you'd disabled the unit, start it manually with
`sudo systemctl start morphit-relay.service`.)
5. Check `systemctl status morphit-relay.service` — should be
`active (running)`.
6. Check `/v1/health` — should report `status: ok` within a
minute.
### Unplanned reboot (systemd crash, kernel panic, VPS
migration)
Same as above but skip the pre-notification. The queue drainer
can be down for hours without user-visible impact: pending
bonuses land eventually, new signups route to the normal
signup endpoint which doesn't require the drainer.
### Forgot the passphrase
See section 8 (owner-key rotation). You'll need to rotate the
active key from paper backup.
---
## 4. Responding to a witness fee change alert
The indexer emits a `[witness-fee] fee_changed` log record when
it observes a change in Blurt's `account_creation_fee`. The
listing fee formula (ADR-0011) auto-adjusts; the alert is
informational.
To grep for this specifically in journalctl:
```sh
sudo journalctl -u morphit-indexer.service | grep '\[witness-fee\] fee_changed'
```
When logs are in JSON mode (`MORPHIT_LOG_FORMAT=json`):
```sh
sudo journalctl -u morphit-indexer.service \
| jq 'select(.module=="witness-fee" and .event=="fee_changed")'
```
### What to check
1. Look at the new fee vs. old. A change of ±5% is normal
witness adjustment; ±50% is unusual and worth investigating.
2. Check witness-governance channels (Blurt Discord, forum) for
discussion of the change.
3. Re-evaluate Morphit's listing fee.
**Note (2026-05-09 docs-fidelity audit):** Earlier
versions of this doc referenced
`MORPHIT_INDEXER_LISTING_FEE_AMORTIZATION_FACTOR` and
`MORPHIT_INDEXER_LISTING_FEE_OPERATIONAL_MARGIN_BLURT` as
the formula coefficients to adjust. Those env vars are
**not in the current code** — they were part of the
USD-anchored fee model superseded by ADR-0011's BLURT-
native amendment (Part 90).
In the BLURT-native model, the listing fee is set
directly by `MORPHIT_INDEXER_FEE_BASE_BLURT` (default
`125`). No amortization formula, no operational margin —
the operator picks a flat BLURT amount per their
tolerance.
So the runbook step is now: review the chain's new
account-creation fee, decide whether your current
`MORPHIT_INDEXER_FEE_BASE_BLURT` still covers the
account-creation cost (relay-funded) plus your operational
margin in BLURT terms, and adjust if needed. Restart
the indexer to pick up the new value.
4. If the new fee makes Morphit unsustainable at your current
signup rate, consider:
- Raising `MORPHIT_INDEXER_FEE_BASE_BLURT` directly.
- Suspending new signups via nginx while you negotiate with
the community.
- Posting a transparency update on the Morphit Blurt
community.
### Don't do
- Don't panic-adjust the fee mid-hour. The indexer's hourly
poll gives you time to think.
- Don't raise `MORPHIT_INDEXER_FEE_BASE_BLURT` more than 2x
in a single change without a community conversation — that
makes new-user onboarding meaningfully more expensive and
surprises users mid-session.
---
## 5. Responding to a relay-queue-stuck alert
If you notice rows in `relay_pending_transfers` with
`error_count` near the `queueMaxRetries` ceiling (default 3),
something is stuck.
### Inspect the queue
```sql
SELECT id, recipient, kind, amount_blurt, reason,
error_count, last_error, last_error_at, created_at
FROM relay_pending_transfers
WHERE broadcast_at IS NULL AND error_count >= 3 -- a stuck row sits at exactly queueMaxRetries (default 3)
ORDER BY error_count DESC, created_at ASC;
```
Common causes:
- **Recipient account doesn't exist on chain.** Usually means
the account was created but then deleted/never funded, or
a bad recipient landed in the queue from a handler bug.
Action: manually null this row's `broadcast_at` to something
non-NULL (e.g. `NOW()`) with `broadcast_trx_id='manual-skip'`
— the drainer will leave it alone.
- **Relay has no BLURT to fund the transfer.** Check the
relay's balance on `blocks.blurtwallet.com`. If it's under
the week's expected disbursement (20 BLURT × pending rows),
top up from the funding account's paper active key.
- **All RPC endpoints unreachable.** Rare. Check
`/v1/health` on the relay; if it reports `stale=true`,
investigate network/DNS to the Blurt RPCs.
### Don't do
- Don't re-queue a row by decrementing `error_count`. The row
is stuck for a reason — fix the root cause first, then
either zero `error_count` (the drainer will retry) or leave
it and let the operator pick it up manually.
---
## 6. Responding to a signup velocity spike
Not yet implemented as an automated alert, but the mechanism
is documented in ADR-0010 §4. Watch relay logs for unusual
signup rates: legitimate growth is gradual; a 10× spike in an
hour is not.
### Check first
1. Is this actual growth? A Reddit/HN mention can legitimately
cause a spike. Look at Morphit's Blurt community for signs
of organic interest.
2. Are the signups coming from distinct IP addresses, or is one
IP making many requests? Nginx's `error_log` shows the
per-IP rate-limit rejections.
### If it's abuse
1. Temporarily tighten nginx's `limit_req` for
`/v1/account/create` to 1/day per IP. Reload nginx.
2. Check `relay_pending_transfers` for signup-dust entries from
suspicious-looking accounts. Investigate patterns (shared
creator, sequential timestamps).
3. If damage has been done (e.g., N fake accounts burned through
the relay's BLURT), don't panic — wait for the pattern to die
down. The relay pauses signups cleanly when its balance can't
cover the fee.
### If it's genuine growth
1. Top up the relay's liquid BLURT (§0a / §1).
2. Consider raising the `MORPHIT_RELAY_CREATE_RATE_PER_DAY`
temporarily to avoid false positives on enthusiastic users
who complete a form multiple times.
---
## 6a. Moderating accounts — reviewing flags and blocking
The indexer raises two account-level abuse signals as it follows
the chain:
- **suspicious_reciprocity** (Self-trade Signal B) — two accounts
mutually exchanging high-star reviews with no other
counterparties (a likely self-trade ring inflating reputation).
- **related_accounts** (Self-trade Signal A) — accounts created
in close temporal proximity by the same creator.
A flag is a *signal, not a verdict.* Investigate before acting —
many legitimate users review each other or sign up together.
### Reviewing flags
```bash
morphit-ops moderation # last 7d, both signals
morphit-ops moderation --since=30d # wider window
morphit-ops moderation --type=related
morphit-ops moderation --json # machine-readable, no prompt
```
Or run bare `morphit-ops` on a terminal and pick **Moderation —
review flags & block accounts** from the menu. The menu also shows
a `⚠ N to review` marker next to that item when there are recent
flags with no block applied to either named account.
The screen lists each flag annotated with the involved accounts'
current block status (`[BLOCKED]`). On an interactive terminal it
then offers block/unblock as the resolution action.
### Blocking an account (instance-local)
```bash
morphit-ops block <account> "optional reason"
morphit-ops unblock <account>
```
Blocking is **instance-local and reversible.** It is NOT a chain
ban and requires no posting key — nothing is broadcast. It records
a row in `operator_blocks` (origin `local`) that hides the
account's listings everywhere this instance serves them:
- the public orderbook (`/v1/orderbook`)
- the per-account view (`/v1/orders/:account`)
- featured slots, the RSS feeds, and the live SSE stream
- the instance's own derived `morphit_native` / depeg price feeds
(a blocked seller's orders are dropped from the median, so they
can't move the price your instance computes from its orderbook)
What blocking does **NOT** do:
- It does not touch the account's BLURT, BTC, XMR, or any funds.
- It does not affect the account on the Blurt chain.
- It does not follow the account to other Morphit instances — the
whole point of federation is that another operator can serve a
user you've blocked, and vice-versa. Your block applies to
**your instance only.**
A blocked user who visits your instance sees a banner explaining
that their posts are blocked on *this* instance, that they remain
visible on every other Morphit instance, and a link to the Agorise
Matrix room to appeal. The optional reason you pass is shown to
them.
> **Fees:** a listing fee already paid (BLURT/BTC/XMR) is not
> refunded on block — the fee bought a listing that is now hidden
> here but still visible on other instances. Blocking is a curation
> choice, not a billing action.
The legacy `morphit-ops abuse` (broadcast failures + signals, 24h)
and `morphit-ops flags` (signals + evidence) subcommands remain
available from the CLI for scripting/JSON, but the menu now routes
moderation through the unified screen above.
---
## 7. Suspected relay compromise
If you have reason to believe the relay VM has been
compromised (unexpected outbound connections, unknown
processes, missing files, sudo audit weirdness):
### Stop the bleeding — in order
1. **Immediately**: `sudo systemctl stop morphit-relay.service`.
This flushes the active key from memory. Any attacker who
was reading memory just lost their prize.
2. **Within 5 minutes**: revoke the weekly `recurrent_transfer`
from the funding account (section 1 "Stopping the recurrent
transfer early"). The funding account's active key is on
paper — you'll need to do this from an air-gapped machine
or trusted workstation. Requires you to leave the relay
host.
3. **Within 30 minutes**: check the `@morphit-relay` account
on chain for unauthorized transfers. If anything moved to
an unknown account, you were compromised.
### Key rotation — within 24h
See section 8. You must rotate `@morphit-relay`'s active key
(and consider owner key) from paper backup. The old key is
presumed exfiltrated.
### Don't do
- Don't delete logs. You want them for forensics.
- Don't restart the service with the old key. If a key is
suspected compromised, rotate before returning to service.
---
## 8. Owner-key rotation ceremony
This is a rare, high-stakes operation. Only do it when:
- An active key has been suspected compromised and you've
rotated the active key from paper (simple case).
- An owner key itself has been suspected compromised (much
harder — requires the recovery-account flow).
The active-key-only rotation is simpler and more common. I'll
document that; owner rotation is out of scope for this runbook
because the ceremony requires the second physical keyholder
(Blurt's recovery-account mechanism) and cannot be done alone.
### Active-key rotation (owner key is safe)
Precondition: you have the paper-backed owner key for
`@morphit-relay`, and a new paper active key you just
generated offline.
1. On an air-gapped machine, generate a new active key and
write down its WIF + pubkey on a fresh sheet of paper.
Burn the intermediate digital copies.
2. On the same air-gapped machine, build and sign an
`account_update` op using the owner key:
```json
[
"account_update",
{
"account": "morphit-relay",
"active": {
"weight_threshold": 1,
"account_auths": [],
"key_auths": [["<new-active-pubkey>", 1]]
},
"memo_key": "<unchanged>",
"json_metadata": ""
}
]
```
3. Sneakernet the signed tx to a networked machine and
broadcast. Verify on chain that `@morphit-relay`'s active
authority now points to the new pubkey.
4. On the relay host, encrypt the new WIF into an envelope:
```sh
cd /opt/morphit/apps/relay
# Write the new WIF to a temporary file (memory-only ideally,
# or tmpfs-backed /run if your host has one):
echo "<new-wif>" > /run/relay-active.tmp
chmod 0400 /run/relay-active.tmp
tsx apps/relay/scripts/encrypt-active-key.ts \
/run/relay-active.tmp \
/etc/morphit/keys/relay-active.enc.new
sudo shred -u /run/relay-active.tmp
```
The `.new` suffix prevents a race if the relay is still
running with the old envelope.
5. Atomically swap the envelope file into place:
```sh
sudo mv /etc/morphit/keys/relay-active.enc.new \
/etc/morphit/keys/relay-active.enc
```
6. Restart the relay (you'll need to enter the passphrase you
chose in step 4 when it prompts). Verify `/v1/health`
returns OK and the queue drainer runs a cycle successfully.
7. **Securely destroy the old active-key paper backup**. Shred
or burn.
8. Update your operator journal: date, reason, new pubkey fingerprint
(first 8 + last 4 chars of the base58 string).
### What NOT to do during rotation
- Don't generate keys on a networked machine. Ever.
- Don't email the new WIF. Don't put it on any sync service.
- Don't skip step 6. Old paper + fresh paper = two attack
surfaces when only one is current.
---
## 9. Paper-key backup protocol
For `@morphit`, `@morphit-relay`, and `@morphit-fees` owner
keys, and for the funding account's active key:
### Creation
1. Generate on an air-gapped machine.
2. Print (not write) to paper — handwriting errors are common.
3. Include a checksum or fingerprint on the paper so the paper's
integrity can be verified without decoding the full key.
4. Two copies minimum. Each in a physically separate location
(e.g., your safe + a trusted agent's safe).
### Storage
- Fire-resistant safe (UL Class 350 or better).
- Not in plain view.
- Not in a safety-deposit box where the institution could be
compelled to open it without your knowledge.
### Periodic check
- Every 6 months: verify both copies exist and are legible.
DO NOT photograph them or bring them near any
internet-connected device.
### Destruction
- When rotating keys, destroy the old paper within 24h of the
rotation broadcast being confirmed on chain.
- Shred at DIN P-7 or burn. A cross-cut shredder at P-4 is
not enough for cryptographic material.
---
## 10. Escalation
If you encounter something this runbook doesn't cover:
- Post in the Morphit Blurt community with `[OPERATOR]` in the
title. Other operators can advise.
- For cryptography-level questions (a chain attack, an
unexpected behavior in dblurt, etc.), the Blurt witness
community on Discord is knowledgeable and responsive.
- For nothing is working and you need to bail out: pause the
relay at nginx (return 503 for `/v1/account/create`) and
post a notice on the Morphit community Blurt account. The
indexer can run independently; users can still view the
orderbook and chat. New signups pause gracefully.
The whole point of Morphit's architecture is that nothing is
lost when the relay is down. Take the time you need to think.
---
## 11. Running integration tests
Unit tests run without external dependencies — `npm test` works
on any developer machine. Integration tests additionally exercise
real Postgres semantics (UPSERT row locks, predicate correctness,
JSON operators, etc.) and only run when a test database is
available.
### When to run
Before any release that touches:
- Migration SQL (any `schema-v*.sql` file)
- Concurrent-path SQL (UPSERT, RETURNING, SELECT FOR UPDATE)
- Aggregations that feed into other queries in the same
transaction (e.g. the loyalty module's SUM(bp_rewarded))
- Anything where "works on a mock client" isn't enough evidence
Concretely: all of Phase 4c (`trackVerifiedBlurtFee`), the fee
attestation handler's `COUNT(DISTINCT attestor)` logic, and any
future handler that compounds state across multiple statements.
### Prerequisites
- A Postgres 15+ instance you don't mind dropping schemas in
- The running user has `CREATE SCHEMA` and `DROP SCHEMA CASCADE`
privileges
- No other Morphit tests pointed at the same database (each
test suite creates its own schema, but dropping the DB while
tests run is still destructive)
You can use a local Docker container:
```sh
docker run --rm -d --name morphit-test-pg \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=morphit_test \
-p 5433:5432 \
postgres:15
```
### Running
```sh
cd apps/indexer
export TEST_DATABASE_URL='postgres://postgres:test@localhost:5433/morphit_test'
npm test
```
Tests gated by the harness `INTEGRATION_ENABLED` flag will run;
the rest run as usual. Without `TEST_DATABASE_URL` set, those
suites print "skipped" and the suite still passes — the default
developer workflow stays fast.
### What to watch for
- **Schema leaks** — teardown runs in `afterAll`; if a test
crashes before afterAll, its schema remains. Safe to manually
clean up with `psql -c "DROP SCHEMA morphit_test_* CASCADE"`
periodically.
- **Shared connection pool** — the harness creates one pg.Pool
per suite. Long-running CI across many suites consumes file
descriptors; set Postgres `max_connections` generously.
- **TEST_DATABASE_URL vs DATABASE_URL** — these are separate.
Never point TEST_DATABASE_URL at your production database.
The harness drops schemas by name; a misconfigured env var
could destroy real data.
### First-run verification
The concurrent-write test in `test/integration/loyalty.test.ts`
is the canary for Phase 4c. If it passes on your database, the
UPSERT + SUM path is serializing correctly on your Postgres
installation's isolation level. If it fails with "lost writes"
or "duplicate milestones", open an issue — do not ship.
---
## 12. XMR viewkey pre-deploy check (retired)
This section previously documented a `verify-xmr-viewkey.ts`
helper script for sanity-checking a `(XMR address, view key)`
pair against a real test transaction.
**The script has been retired.** Part 108++ replaced view-
key-based XMR fee verification with per-payment proofs, and
Part 109 removed the `MORPHIT_INDEXER_XMR_FEE_VIEWKEY` env
var entirely. No view key lives on any Morphit indexer's
box. There is no `(address, viewkey)` pair to sanity-check
because there is no view key.
**What to do instead — verifying your XMR fee address is
correctly configured:**
1. After setting `MORPHIT_INDEXER_XMR_FEE_ADDRESS`, restart
the indexer.
2. Query `/v1/release` and confirm the returned
`treasury.xmr.address` matches what you configured.
3. Ask a trusted contact to send a small amount of XMR to
your fee address and generate a tx_proof from their own
wallet (`get_tx_proof <txid> <address>` in monero-wallet-
cli, or the equivalent menu item in any modern Monero
wallet).
4. Submit the txid + proof through the public Morphit UI
the same way a real user would. If the order verifies,
your config is correct.
This end-to-end check exercises the exact code path users
will hit. Misconfiguration surfaces immediately and looks
the same as a real-user error — no operator-only diagnostic
mode needed.
---
## 13. Responding to a stale BLURT/USD price feed
Under Phase 5, the indexer runs a composite BLURT/USD price
source: an outlier-rejected **median across several external
feeds** (Coingecko, CoinPaprika, CryptoCompare, and — for assets
they list — Kraken/Binance/Coinbase/OKX/Bybit and the optional
key-gated CoinCap/Messari), then `morphit_native`, then the
static floor. Values refresh every 5 minutes in the background. When
every upstream fails, the indexer keeps serving the last good
value with `stale=true`. If no upstream has ever succeeded
since boot, it falls back to the static floor
(`MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR`). For BLURT
specifically, Blurt's own `api.blurt.blog/price_info` feed is an
authoritative PRIMARY tier tried *before* this median — when it
answers plausibly it sets the BLURT price directly, and the median
and lower tiers are only its fallback (see the cp425 / cp604 note
below).
**cp128 update — denomination is operator-configurable**: by
default the BLURT price echo on `/v1/listing-fee` is expressed
in USD. Operators serving non-USD markets (or hedging against
USD erosion / petrodollar collapse) can set
`MORPHIT_INDEXER_PRICE_FEED_DENOMINATION_FIAT` to any 3-8
character uppercase ticker — `EUR`, `GBP`, `JPY`, `BRL`, `CNY`,
`INR`, `RUB`, `AED`, `XDR` (IMF Special Drawing Rights basket),
`XAU` (gold ounces, hard-currency hedge), etc. The listing-fee
endpoint then returns a `denomination_fiat` field telling
frontends which unit the `blurt_price_fiat` and `base_fee_fiat`
values are expressed in. This is purely a display-side change;
order matching and on-chain fees are unaffected. See ADR-0040.
**cp129 update — Defense F cross-instance peer disagreement
detector**: opt-in via `MORPHIT_INDEXER_PEER_PRICE_MONITOR_ENABLED=true`.
When on, the indexer periodically (every 30 min) queries peer
Morphit instances' `/v1/price/morphit-native/receipt` and alerts
on sustained disagreement >25% for >4 hours. Catches the case
where YOUR indexer is reporting a different price than the rest
of the federation (operator pressured, captured, compromised,
or geographically isolated). Requires ≥3 reachable peers in
`/v1/instances` with `last_probe_status` good/quiet; below that
the monitor degrades silently to no-alert. Alerts surface in
`peer_price_disagreement_alert` log entries with the deviation
percentage, peer median, and your own price. Investigation
runbook: see "Responding to a peer-price-disagreement alert"
below. See ADR-0041.
**cp130 update — multi-asset morphit_native (BTC + XMR added
alongside BLURT)**: when the price feed is enabled, the indexer
now creates three independent composite price sources at boot —
one per asset. Every asset (BLURT, BTC, XMR) prices from the
same tiered design: an outlier-rejected **median across several
external feeds**, then `morphit_native`, then the static floor.
Klingex, the former BLURT-only upstream, went out of business
in 2026 and was removed; rather than fall back to a single
replacement, the external tier now averages many independent
feeds (Coingecko + CoinPaprika + CryptoCompare for every asset;
plus Kraken/Binance/Coinbase/OKX/Bybit where the asset is listed;
plus CoinLore and the key-gated CoinCap/Messari when configured).
A single provider banning us, rate-limiting us, or returning a
bad number can no longer move the published price — any feed that
returns nothing is simply dropped from the median.
Each source has its own cache and refresh schedule. Two new
env vars set per-asset static floors:
`MORPHIT_INDEXER_PRICE_FEED_BTC_STATIC_FLOOR` (default 64700) and
`MORPHIT_INDEXER_PRICE_FEED_XMR_STATIC_FLOOR` (default 333).
The cp129 peer-price monitor now spawns one instance per asset,
so disagreement on BTC alerts separately from disagreement on
BLURT — and each asset is sampled independently from peers.
Receipt endpoint `/v1/price/morphit-native/receipt?asset=BTC`
returns a real BTC/USD derivation operators can inspect.
See ADR-0042.
**cp425 / cp604 update — `api.blurt.blog/price_info` is the PRIMARY
BLURT/USD source of truth**: for BLURT, the indexer reads Blurt's own
`api.blurt.blog/price_info` feed (configurable via
`MORPHIT_INDEXER_BLURT_PRICE_FEED_URL`, default
`https://api.blurt.blog/price_info`; set it empty to opt out) **first,
every cycle** — and whenever it returns a plausible value, that value IS
the published BLURT price, and the external CEX aggregators are not
queried that cycle. It's a self-sovereign, non-CEX source that fits
Morphit's decentralization priority. Only when the Blurt feed is down or
returns an implausible number does the indexer fall back, in order, to
the outlier-rejected median across the external aggregators (Coingecko /
CoinPaprika / CryptoCompare, plus the listed CEXes), then
`morphit_native`, then the static floor. This primary-first behavior is
BLURT-only and USD-only (the feed quotes BLURT/USD); BTC and XMR still
take the aggregator median as their primary tier. If you firewall the
indexer's outbound traffic, allow `api.blurt.blog` alongside the other
price hosts. The feed appears as `blurt_price_feed` in the `morphit-ops
health` per-source list — normally the committed-source row. Effective
fallback for staleness is unchanged: when the primary and the aggregators
are all temporarily down, the indexer keeps serving the last committed
value (`stale=true`), not the static floor — the static floor only
applies at cold-start before any source has ever answered.
### Responding to a peer-price-disagreement alert
If you see a `peer_price_disagreement_alert` event in the
indexer logs, the cp129 peer-price monitor has detected that
your indexer's derived BLURT price has been diverging from the
peer median by more than 25% for sustained 4+ hours. This is a
warning signal, not a hard failure — your instance keeps
serving prices as normal.
The questions to investigate, in order:
1. **Is your morphit_native fetcher healthy?** Check
`/v1/price/morphit-native/receipt` on your instance. Look at
`derived_price`, `tier_used`, `contributing_traders`. If the
tier is unexpected (e.g. you expected Tier 1 USD-direct but
got Tier 3 hybrid), some trader population shifted.
2. **What are peers reporting?** Pick a few from `/v1/instances`
and hit their `/v1/price/morphit-native/receipt` directly.
Compare their numbers and contributing-trader sets to yours.
3. **Is there genuine market dislocation?** Check Coingecko
for BLURT/USD. If both external sources agree
with peers but your indexer's native fetcher disagrees, it's
your instance. If externals agree WITH your native price
but disagree with peers, it's the peers (look for whether
peers might be on a stale price-source state).
4. **Has someone manipulated your on-platform data?** This is
the threat the alert exists for. Check recent verified-fee
orders for unusual patterns: new traders posting
abnormally-priced BLURT-vs-fiat orders, large concentrated
positions, etc. The cp127 sybil filters should have caught
most of this; an alert that survives the filters might mean
a new attack pattern.
5. **Are you in a peer-poor segment?** Geographic or network-
isolation scenarios can produce false alerts because your
sample of "peers" is itself non-representative. Check that
you have ≥3 peers reachable across diverse networks (not all
on the same hosting provider, not all on Tor only, etc.).
If the alert is a false positive (after investigation, your
price is correct and peers are wrong): the alert auto-suppresses
for 24h after firing, then re-fires if disagreement persists.
You can also temporarily set
`MORPHIT_INDEXER_PEER_PRICE_MONITOR_ENABLED=false` and restart;
this stops querying peers entirely. Re-enable once the
underlying situation resolves.
If the alert is a true positive (your indexer is wrong): pause
fee acceptance until you've identified the root cause; users
trading against bad price displays could be misled about value.
Investigate as above; possibly restart with the price feed
disabled (`MORPHIT_INDEXER_PRICE_FEED_ENABLED=false`) so your
instance falls back to the static floor while you fix things.
**cp127 update — self-sovereign price source (morphit_native)**:
once your instance has enough on-platform trading volume, you
can flip on `MORPHIT_INDEXER_PRICE_FEED_NATIVE_ENABLED=true` to
add a new upstream slotted BETWEEN coingecko and the static
floor. The native fetcher derives BLURT/USD from real verified-
fee on-platform orders and survives external-feed outages
entirely (Coingecko rate-limits or outages, etc.). See
ADR-0039 for full design and `/v1/price/morphit-native/receipt`
for live operator-side inspection of what it's producing.
Defaults to OFF so a brand-new instance with zero trade history
doesn't try to derive from empty data.
> **Quick action:** if you just need to update the static
> floor during an outage, set `MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR`
> in your SystemD `Environment=` directive (or wherever your
> deployment manages env vars) and restart the indexer. The
> runbook below is for when you also want to investigate why
> the upstreams are failing.
### When to investigate
- `/v1/health?verbose=1` reports `diagnostics.price.stale=true`
- `/v1/health?verbose=1` reports `diagnostics.price.source` as
`static_floor` while `MORPHIT_INDEXER_PRICE_FEED_ENABLED=true`
(means no price source — the primary Blurt feed, the aggregator
median, or `morphit_native` — has succeeded since boot, so the
indexer is falling back to
`MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR`)
- Log aggregator shows repeated `[price] all_upstreams_failed_serving_cache`
or `all_upstreams_failed_no_cache_serving_floor` events from
the indexer
- Users report fee quotes that seem out of line with live BLURT
price
### Quick diagnosis
Query the verbose health endpoint:
```sh
curl -s http://localhost:PORT/v1/health?verbose=1 | jq .diagnostics.price
```
Example healthy output:
```json
{
"blurt_usd": 0.00423,
"source": "blurt_price_feed",
"updated_at": "2026-04-20T12:35:00.000Z",
"stale": false
}
```
Example stale output (upstream is down, cache is aging):
```json
{
"blurt_usd": 0.00423,
"source": "coingecko",
"updated_at": "2026-04-20T10:00:00.000Z",
"stale": true
}
```
Example all-upstreams-failed-since-boot:
```json
{
"blurt_usd": 0.002,
"source": "static_floor",
"updated_at": "1970-01-01T00:00:00.000Z",
"stale": true
}
```
### What to check
1. **Is the price actually wrong?** The `static_floor` is your
`MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR` — if you've kept that
reasonably close to market, stale behavior is not
user-visible as incorrect fees.
2. **Is Coingecko reachable + not rate-limited?**
```sh
curl -fsS --max-time 5 "$MORPHIT_INDEXER_COINGECKO_BASE_URL/simple/price?ids=blurt&vs_currencies=usd"
```
A 429 response means you're rate-limited; the free tier
allows ~10-30 req/min shared across all callers from your
IP. With a 5-minute refresh this should be comfortable, but
a shared IP (NAT, proxy) can exhaust it. The price is a
median across many external feeds now (Klingex, the former
BLURT primary, went out of business in 2026), so one feed
rate-limiting or going down just drops it from the median;
if ALL external feeds fail the indexer serves the last cached price, then
the static floor.
3. **Has the Coingecko API shape drifted?** Our Coingecko
fetcher tries several field names defensively, but a
wholesale rename could surface as `unexpected_shape` log
events. Open a bug with the current endpoint's JSON payload
and we'll extend the parser.
### Immediate mitigation
If the feed is stale and you need the live price reflected in
listing-fee quotes **right now**, adjust
`MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR` to match current market and
restart the indexer. The static floor becomes the served value
immediately. Revert to the live feed once the upstream is back
up — there's no penalty for overriding temporarily.
### Don't do
- Don't remove the static floor entirely. It exists so the
indexer never hard-fails on a price lookup; without it, a
total-upstream-outage would break fee verification entirely.
- Don't raise `MORPHIT_INDEXER_PRICE_REFRESH_INTERVAL_MS` to
reduce traffic during an outage. The upstreams don't call
you back when they recover — a longer interval just means
longer staleness when they do.
- Don't switch `MORPHIT_INDEXER_PRICE_FEED_ENABLED` to `false`
permanently "because it's simpler." The composite chain
self-heals; static drifts from market and produces
user-complaint fees within weeks.
### Consistency guarantee
The same `priceSource.current()` value is used for both the
quote at `/v1/listing-fee` AND fee-verification in the order
handler. So even during a stale episode, a user who pays the
quoted fee verifies correctly — you just may be quoting prices
slightly off market. Stale is a UX quality issue, not a
correctness bug.
---
## 14. Deployment topology requirement — apps MUST be behind a loopback proxy
The indexer and relay rate-limiters derive the client IP
from forwarded-address headers (`X-Real-IP`,
`X-Forwarded-For`) only when the immediate socket peer is
a loopback address. This is a security property
(preventing rate-limit bypass via forged headers) and it
dictates a deployment requirement:
**The indexer and relay MUST NOT be exposed directly to
the public internet.** They MUST be reached only through
an nginx (or equivalent) reverse proxy running on the
same host.
### Why this matters
If a user can connect directly to port 8080 (the relay)
or the indexer's listen port, they can send arbitrary
`X-Real-IP` / `X-Forwarded-For` headers. Before the fix
shipped for Findings B + E (April 2026), forwarded
headers were honored unconditionally — a direct
connection could forge a fresh IP per request and bypass
the rate limiter.
The fix now discards forwarded headers from non-loopback
peers. This closes the vulnerability **only if the
deployment actually fronts the apps with a loopback
proxy**. If you deploy the apps directly on a public
port, the socket peer will be the real attacker IP and
the rate limit will work correctly — but every attacker
shares one bucket per IP, so a botnet spread across
thousands of IPs would still bypass the limiter. A
proxy-fronted deployment is what the system is designed
for.
### Verification procedure (do this during setup)
```bash
# From a machine that is NOT the app host, try to reach
# the app directly. Replace <PORT> with the indexer's
# listen port (default 8081) when probing /v1/health, or
# the relay's listen port (default 8080) for relay
# endpoints. Replace <host> with the server's public IP
# or hostname.
curl -v -H "X-Real-IP: 1.2.3.4" http://<host>:<PORT>/v1/health
```
Expected outcome: **connection refused or times out**.
If you get a response, the app is reachable directly
and your deployment is vulnerable. Fix by binding the
app to `127.0.0.1` only:
- Indexer: `MORPHIT_INDEXER_LISTEN_HOST=127.0.0.1`
in the systemd service env file.
- Relay: bind `127.0.0.1:8080` in the relay config (see
`apps/relay/src/main.ts` and its config).
- Firewall: `ufw deny 8080 && ufw deny 8081` as
defense-in-depth against misconfiguration.
### Spot-check during operation
The reference deployment (nginx on same host, apps bound
to loopback) keeps the invariant automatically. If you
ever move nginx off-host or add a CDN in front, update
the app's `LOOPBACK_PEERS` allowlist in both
`apps/relay/src/middleware/ip.ts` and
`apps/indexer/src/api/middleware/ratelimit.ts` to
include the proxy's source IP. Don't skip this — the
current hardcoded `['127.0.0.1', '::1', '::ffff:127.0.0.1']`
is correct for the reference deployment but wrong for
off-host-proxy deployments.
### Recommended single-hostname layout (zero DNS for relay/indexer)
The frontend's default `MORPHIT_RELAY_ORIGIN` is `/relay` and
`MORPHIT_INDEXER_ORIGIN` is empty (same origin) — both resolve
to **same-origin paths**: the relay is reached under `/relay/`,
the indexer under `/v1/` (plus its RSS feeds under `/rss/`).
With the recommended nginx config below, your users reach
everything under one public hostname and you do NOT need
separate DNS entries for the relay or indexer.
```nginx
# Public HTTPS virtual host — the only hostname users see.
server {
listen 443 ssl http2;
server_name morphit.example.com;
ssl_certificate /etc/letsencrypt/live/morphit.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/morphit.example.com/privkey.pem;
# Security headers: see §15 for the full CSP and other
# headers. This block is only about routing.
# Frontend — static files from the Morphit build output.
root /var/www/morphit-frontend;
index index.html;
location / {
try_files $uri $uri.html $uri/index.html /index.html;
}
# Relay — fund-spending endpoints. Proxied to loopback.
location /relay/ {
# Strip the /relay prefix before forwarding so the
# relay sees /v1/account/create, not /relay/v1/...
rewrite ^/relay/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Indexer — public API. Proxied to loopback.
# The frontend hits the indexer at same-origin `/v1/*`
# (e.g. `/v1/orderbook`, `/v1/instance`). The indexer serves
# those paths as-is, so forward them unchanged — NO rewrite.
# This block proxies ALL methods, so it also carries the cp344
# write proxies the browser uses instead of talking to a public
# Blurt RPC node directly: `POST /v1/broadcast` (relays a user-
# signed tx to the chain server-side) and `GET /v1/chain/properties`
# (the ref-block read that precedes a broadcast). No extra config —
# POST is handled by this same location block.
# (`/relay/v1/*` is caught by the longer `/relay/` prefix
# above, so it never falls into this block.) For the SSE
# stream endpoints (`/v1/.../stream`) add the streaming +
# connection-cap block from §24.
location /v1/ {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Operator-only per-source price-feed health (morphit-ops health)
# is gated on X-Morphit-Local-Health, sent by the local ops-cli
# over the bridge. Clear any client-supplied value here so a
# public caller can never forge it.
proxy_set_header X-Morphit-Local-Health "";
}
# Indexer RSS feeds. Same backend as `/v1/`, but mounted at
# the bare `/rss/` path because feed readers expect
# `/rss/orderbook.xml` (not `/v1/rss/...`). Without this block,
# the RSS pill in the frontend footer and the per-trader /
# per-asset subscribe links return 404.
location /rss/ {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
Adjust the loopback ports to match your actual
`MORPHIT_RELAY_LISTEN_PORT` and
`MORPHIT_INDEXER_LISTEN_PORT` if they aren't 8080 / 8081.
With this layout:
- No `relay.morphit.example.com` DNS record required.
- No `indexer.morphit.example.com` DNS record required.
- `MORPHIT_RELAY_ALLOWED_ORIGINS` needs only the one public
hostname (`https://morphit.example.com`) — see §17.
- CSP `connect-src` is covered by `'self'` (no extra hosts
to allowlist) — see §15.
### Split topology (if you prefer distinct subdomains)
If you want the relay and indexer on their own subdomains
(e.g. to let different teams own them, or to deploy them on
different VPSes), build the frontend with overrides:
```
# In your frontend build environment:
MORPHIT_RELAY_ORIGIN=https://relay.morphit.example.com
MORPHIT_INDEXER_ORIGIN=https://indexer.morphit.example.com
```
In that case add those hostnames to your CSP `connect-src`
(see §15) and set `MORPHIT_RELAY_ALLOWED_ORIGINS` to the
frontend's origin (NOT the relay's — Origin is always the
page-loading origin). See §17 for details.
### SSE connection caps (mandatory hardening)
The indexer exposes three Server-Sent Events endpoints used by
the frontend for real-time updates:
- `/v1/orderbook/stream` — orderbook changes
- `/v1/chat/:a/:b/stream` — chat messages
- `/v1/instances/stream` — federation directory
These endpoints are deliberately **not** behind the per-minute
rate-limit middleware. A long-lived SSE connection is one HTTP
request; per-minute limit doesn't model resource cost. The right
control is a **concurrent-connection cap** at the reverse proxy.
**Without these caps, an attacker can open thousands of SSE
connections from a single IP and exhaust the indexer.** Add both
blocks below to your nginx config.
```nginx
# limit_conn zones — declared in the http {} block.
http {
limit_conn_zone $binary_remote_addr zone=sse_per_ip:10m;
}
# Add a stream-specific location. As a regex it is matched
# ahead of the general `/v1/` prefix block from §14, so only the
# long-lived SSE connections get the connection cap + streaming
# tuning — normal REST under `/v1/` keeps default buffering and
# is unaffected by the cap.
server {
location ~ ^/v1/.*/stream$ {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# SSE-specific tuning. The indexer sends a `:keepalive`
# comment every 25s, so 5m is ample headroom; buffering
# off is REQUIRED or events arrive in batches, not live.
proxy_read_timeout 5m;
proxy_buffering off;
proxy_cache off;
# Per-IP cap: 20 concurrent SSE connections per source IP.
limit_conn sse_per_ip 20;
limit_conn_status 429;
}
}
```
For Caddy, use a comparable connection-limit plugin. The cap value
(20) is adjustable; raise to 50 for high-traffic instances.
### TLS certificates and auto-renewal (Let's Encrypt + nginx)
The nginx config above references
`/etc/letsencrypt/live/morphit.example.com/{fullchain,privkey}.pem`.
This section is the canonical guide for obtaining and
auto-renewing those certificates. **Operators using Caddy can
skip this section** — Caddy handles TLS automatically when the
config has a public hostname (this is what RUN-A-MORPHIT-NODE.md
recommends).
> **`morphit-ops ssl` surfaces all of this.** Run `morphit-ops
> ssl` (or the "SSL/TLS certificate (HTTPS)" menu item) for a
> read-only status — whether you have a valid cert for your
> domain, when it expires, and whether the auto-renewal timer is
> actually running. `morphit-ops ssl setup` checks prerequisites
> and prints the exact certbot commands below, tailored to your
> configured domain. (It does not run certbot or edit nginx for
> you — cert issuance changes your web server, so you run the
> steps and review the changes, the same reason service install
> is a hands-on step.)
#### Prerequisites
- DNS A/AAAA records for your domain pointing at this server's
public IP. Verify with `dig +short morphit.example.com`
before proceeding — certbot's HTTP-01 challenge requires
the domain to resolve to this host.
- Port 80 reachable from the public internet (the HTTP-01
challenge is served over plain HTTP).
- The server's firewall (`ufw`, `firewalld`, etc.) allows
inbound 80 and 443.
#### Initial certificate issuance
On Debian/Ubuntu:
```bash
apt update && apt install -y certbot python3-certbot-nginx
```
Stop nginx temporarily so certbot's standalone challenge can
bind to port 80, OR use the nginx plugin which can read your
existing config:
```bash
# Standalone mode (simplest; nginx must be stopped):
systemctl stop nginx
certbot certonly --standalone \
-d morphit.example.com \
--agree-tos \
--email you@example.com \
--no-eff-email
systemctl start nginx
```
Or the nginx plugin (no downtime, but requires your nginx
config to already serve the domain over HTTP):
```bash
certbot --nginx -d morphit.example.com \
--agree-tos --email you@example.com --no-eff-email
```
After success, certs are in `/etc/letsencrypt/live/<domain>/`.
The nginx plugin also patches your config to redirect HTTP→HTTPS
and adds the `ssl_certificate*` directives — review the diff.
#### Auto-renewal (the part most operators get wrong)
certbot installs a systemd timer (or cron job) that runs twice
daily and renews any certificate within 30 days of expiry.
Verify it's enabled:
```bash
systemctl list-timers | grep certbot
# Expected: certbot.timer active ... certbot.service
```
If the timer isn't there, enable it:
```bash
systemctl enable --now certbot.timer
```
After renewal, nginx must reload to pick up the new cert.
certbot's package on Debian/Ubuntu includes a `--deploy-hook`
that handles this, but verify by checking the renewal config:
```bash
cat /etc/letsencrypt/renewal/morphit.example.com.conf | grep -i hook
# Expected: deploy_hook = systemctl reload nginx
```
If no deploy-hook is configured (standalone-mode installs may
miss this), add one:
```bash
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
#!/bin/sh
systemctl reload nginx
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
Hooks in `/etc/letsencrypt/renewal-hooks/deploy/` run after
every successful renewal regardless of the per-domain config.
#### Test the renewal flow end-to-end
```bash
certbot renew --dry-run
# Expected output ends with:
# Congratulations, all simulated renewals succeeded
```
This exercises the full path (challenge, issuance, deploy hook)
without actually consuming a Let's Encrypt rate-limit slot.
Run this once when you set up, and once after any nginx config
change that touches the listening server block.
#### Monitor expiry independently
certbot can fail silently — the renewal timer might run but the
challenge might fail (DNS change, firewall change, port 80
blocked). Set a calendar reminder ~14 days out OR add a check:
```bash
# Add to /etc/cron.weekly/check-cert-expiry.sh:
#!/bin/sh
DAYS=$(echo | openssl s_client -servername morphit.example.com \
-connect morphit.example.com:443 2>/dev/null | \
openssl x509 -noout -enddate | \
sed 's/notAfter=//' | xargs -I{} date -d {} +%s | \
xargs -I{} expr \( {} - $(date +%s) \) / 86400)
if [ "$DAYS" -lt 21 ]; then
echo "WARN: TLS cert expires in $DAYS days" | mail -s "Morphit cert expiry" you@example.com
fi
```
#### Cipher and protocol hardening
The default nginx + Let's Encrypt config is reasonable, but
add explicit modern-only protocols and a strong cipher list
to the server block:
```nginx
server {
listen 443 ssl http2;
server_name morphit.example.com;
ssl_certificate /etc/letsencrypt/live/morphit.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/morphit.example.com/privkey.pem;
# Modern protocol set — TLS 1.2 minimum, TLS 1.3 preferred.
# SSL 3, TLS 1.0 and 1.1 have been broken / are deprecated.
ssl_protocols TLSv1.2 TLSv1.3;
# Mozilla "intermediate" cipher list — works with everything
# ≥ Firefox 27, Chrome 30, IE 11, Safari 9, Android 4.4.
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off; # TLS 1.3 makes this irrelevant.
# Session reuse for performance.
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP stapling — clients verify cert revocation via the
# server rather than calling the CA's OCSP responder.
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 9.9.9.9 valid=300s;
resolver_timeout 5s;
# rest of the server block…
}
```
Test your TLS posture with [SSL Labs](https://www.ssllabs.com/ssltest/)
— aim for an A or A+ rating.
### OS hardening (Debian/Ubuntu reference)
The config above protects the application surface; this section
covers the host. **All operators should apply these baselines.**
RUN-A-MORPHIT-NODE.md mirrors the same commands at a beginner-
friendly level, but this is the canonical reference.
#### Automatic security updates
Without unattended security upgrades, a kernel-level vulnerability
on your VPS sits unpatched until you remember to ssh in.
```bash
apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades # answer "Yes"
```
Verify the security pocket is enabled:
```bash
grep -E '^\s*"\${distro_id}:\${distro_codename}-security"' \
/etc/apt/apt.conf.d/50unattended-upgrades
# Expected: line uncommented
```
For unattended kernel upgrades you'll need to add automatic
reboots on a maintenance window:
```bash
cat >> /etc/apt/apt.conf.d/50unattended-upgrades << 'EOF'
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
EOF
```
Note: a relay reboot interrupts in-flight signups. Schedule the
reboot window for low-traffic hours.
#### Firewall (ufw)
```bash
apt install -y ufw
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP (Let's Encrypt + redirect)
ufw allow 443/tcp # HTTPS
ufw --force enable
```
**Critically: do NOT expose the relay (8080) or indexer (8081)
ports directly.** They MUST sit behind nginx (loopback only —
see §14). Verify with:
```bash
ss -ltnp | grep -E ':8080|:8081'
# Expected: 127.0.0.1:8080 / 127.0.0.1:8081 only
# NOT: 0.0.0.0:8080 or *:8080
```
If you see `0.0.0.0:` here, your relay/indexer is publicly
exposed and the loopback enforcement (§14) failed. Stop and
fix this before continuing.
#### SSH hardening
```bash
# Disable root login over SSH and require key authentication.
# Confirm you have a working SSH key login as a non-root user
# BEFORE running these — getting locked out of a fresh VPS is
# a real possibility otherwise.
# Edit /etc/ssh/sshd_config:
sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
# Validate the config before reload.
sshd -t
systemctl reload ssh
```
#### Brute-force protection (fail2ban)
```bash
apt install -y fail2ban
cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
[nginx-limit-req]
enabled = true
filter = nginx-limit-req
logpath = /var/log/nginx/error.log
maxretry = 10
EOF
systemctl enable --now fail2ban
fail2ban-client status
```
The `nginx-limit-req` jail catches IPs that hit nginx's
`limit_req_zone` (your application-level rate-limit) repeatedly.
Without it, an attacker hitting the relay's signup endpoint at
the rate-limit threshold from a single IP can stay just below
the per-IP velocity rules forever.
#### Filesystem permissions baseline
Verify the relay's data dir and config aren't world-readable:
```bash
chmod 700 /var/lib/morphit /var/lib/morphit/relay
chown -R morphit:morphit /var/lib/morphit
chmod 0640 /etc/morphit/relay.env # if you use an env file
chown root:morphit /etc/morphit/relay.env # 0640 root:morphit, per §37.10/§37.19
```
The relay's encrypted-keystore file is itself encrypted, but
defense in depth: don't let other users on the host read the
ciphertext or the env file with the passphrase pointer.
#### Logging discipline
```bash
# journald: cap log retention to bound disk use.
sed -i 's/^#*SystemMaxUse=.*/SystemMaxUse=2G/' /etc/systemd/journald.conf
sed -i 's/^#*SystemMaxFileSize=.*/SystemMaxFileSize=200M/' /etc/systemd/journald.conf
systemctl restart systemd-journald
```
The relay and indexer log via stdout/stderr captured by systemd's
journal. Without these caps, a chatty error loop can fill `/var`
until the disk is full and the host wedges.
#### Optional but recommended
- **AIDE / Tripwire** — file integrity monitoring. Catches
rootkit / supply-chain compromise that modifies binaries.
Beyond the scope of this guide; standard sysadmin work.
- **auditd** — kernel-level audit logging. Same comment.
- **Fail-closed swap** — if you use swap, encrypt it
(`/etc/crypttab` with a random key per boot) so suspended
process memory isn't recoverable from disk.
#### Securing operator-only routes (cp116)
Morphit exposes a small surface of operator-helpful routes
that don't make sense for end users. Currently this is just
`/admin/setup-wizard` (cp116, config-line generator —
read-only, no mutation, but visually cluttery for end users),
but more may follow.
These routes are NOT auth-gated at the application level by
design — making them read-only sidesteps the need for an auth
system that adds attack surface for marginal benefit. If you
prefer to hide the admin surface from your users anyway, two
common options:
**Nginx HTTP basic-auth:**
```nginx
location /admin/ {
auth_basic "Operator only";
auth_basic_user_file /etc/nginx/.morphit-admin-htpasswd;
# If your frontend is served by nginx as static files
# (production default), use `try_files` here instead of
# proxy_pass. The example below covers the dev-server
# case (SvelteKit dev on :3000); production usually wants:
# try_files $uri $uri/ /index.html;
proxy_pass http://localhost:3000;
}
```
Generate the htpasswd file with `htpasswd -c /etc/nginx/.morphit-admin-htpasswd <username>`.
**Caddy basicauth directive:**
```caddy
your-domain.example {
handle /admin/* {
basicauth {
<username> <bcrypt-hash>
}
reverse_proxy localhost:3000
}
handle {
reverse_proxy localhost:3000
}
}
```
Generate the bcrypt hash with `caddy hash-password`.
For both: pick a username and password unrelated to any of
your Blurt account names — the admin surface is unauthenticated
in the application but you don't want a passive observer of the
HTTP traffic to learn your relay account name.
Locale-prefixed routes (`/en/admin/...`, `/es/admin/...`, etc.)
must all be covered. The route's canonical URL form is
`/<locale>/admin/setup-wizard`; the bare `/admin/setup-wizard`
form gets client-side JS-redirected to the locale-prefixed
form on first hit (see `apps/web/src/routes/+page.svelte`), so
your auth rule should match `^/[a-z]{2}(?:-[A-Z]{2})?/admin/`
to cover all 10 locale variants.
#### Optional: confidential-computing host (AMD SEV-SNP / Intel TDX)
This is **optional defense-in-depth, not a requirement** — most
operators can and should skip it. If your VPS or bare-metal host
offers a confidential-computing mode (AMD SEV-SNP or Intel TDX),
enabling it encrypts the guest's RAM at the hardware level. The
only long-lived secret Morphit keeps in process memory is the
operator's Blurt **posting** key (used by the relay to broadcast
signup dust and listing-fee splits — never a user's funds, which
Morphit does not hold at all). Hardware memory encryption protects
that one key against a malicious host operator or co-tenant who can
read raw VM memory.
Two honest caveats, because we will not oversell this:
- Morphit makes **no TEE/enclave attestation claim** and does not
depend on SEV/TDX. The relay does not run inside an attested
enclave, and we will not market "TEE-attested" security we have
not earned. A SEV/TDX host adds a memory-secrecy layer; it does
not change Morphit's trust model.
- A TEE roots part of your trust in the CPU vendor (AMD/Intel) and
your cloud provider — which is in tension with priority #2
(decentralization). That is why this is opt-in for operators who
specifically want it, not a default we push.
For the threats that actually matter on a no-funds relay, Morphit's
defaults already do the heavy lifting: Tor-onion reachability on by
default, a strict CSP, Subresource Integrity on every script, the
on-chain `morphit_release_v1` hash manifest (so users verify the
served bundle independently of the host — see §15 and the release
sections), `ufw`/`fail2ban`, and the client-side Argon2id + AES-GCM
key envelope (user keys are encrypted in the browser and never reach
the server in the clear). SEV/TDX is a nice extra for the paranoid,
not a gap in the baseline.
### Tor-only nodes (no clearnet domain)
The guided installer (`morphit-ops install`) offers a **Tor-only** node:
choose it at the "How will people reach your marketplace?" question and
the node runs with **no clearnet domain** — reachable only over its
auto-generated Tor `.onion` (plus `.i2p`/`.b32.i2p` when i2pd is present).
This is the strongest posture for priority #1 (privacy) and #2
(unstoppability): there is no domain to seize, no certificate authority in
the trust path, no public 80/443 to firewall, and no home port-forward.
What changes under the hood (all automatic):
- The wizard skips the domain, HTTPS-certificate email, dynamic-DNS and
router-port-forward questions (so it's a few steps shorter — the "Step N
of {total}" counter reflects this).
- Ansible sets `morphit_tor_only: true`, which makes `enable_tls` false (no
certbot / Let's Encrypt) and drops BunkerWeb's clearnet TLS services from
the compose. The **frontend still runs** and Tor fans the onion straight
into it on `127.0.0.1:8090` (the same path a clearnet node's onion uses).
- The advertised origin — `MORPHIT_INSTANCE_ORIGIN`, the relay's
`MORPHIT_RELAY_PUBLIC_ORIGIN` and its CORS allowlist — is set to
`http://<onion>` once the tor role has generated it (the tor role runs
after the templates render, so a post-task fills these in and restarts the
indexer + relay). `http://` is correct here: Tor encrypts and authenticates
the connection at the network layer, so a clearnet TLS certificate is
neither obtainable nor meaningful for a `.onion`.
- The node registers on-chain by its onion and appears in the federated
`/instances` directory with a **"No clearnet reliance"** label; other
indexers that run Tor probe it over Tor for a real status (a clearnet-only
indexer simply lists it on its signed registration).
**Adding a clearnet domain later.** A Tor-only node isn't a dead end. When
you're ready to also serve `https://`:
1. Buy a domain and point an **A record** at the box's public IP (home
nodes: forward ports 80 + 443 to the box, and set up dynamic DNS —
§"Dynamic DNS").
2. Obtain the certificate and bring up the clearnet edge with
`morphit-ops ssl setup <domain>` (this runs the TLS role + BunkerWeb for
the domain; the onion keeps working alongside it).
3. Update your advertised origin: `morphit-ops register` (or, if already
registered, the operator-update flow) with `MORPHIT_INSTANCE_ORIGIN=https://<domain>`.
The onion stays advertised as an alt-network address, so nothing you've
published breaks — you're adding clearnet, not replacing Tor.
---
## 15. Frontend CSP + security headers for operators
The frontend is a static (adapter-static) build served by your
web server, so its Content-Security-Policy is delivered as an
HTTP **response header**, not a `<meta http-equiv>` tag. As of
cp233 the build no longer emits a meta CSP at all — earlier it
did, via SvelteKit's `kit.csp`, but a meta CSP (a) cannot
enforce `frame-ancestors`, (b) was stricter than the app needs
(it blocked the in-browser WASM crypto and the inline
bootstrap), and (c) was intersected with the header CSP by the
browser, so the meta clobbered the working header and operators
had to `sed` it out of the build by hand. The nginx header
below is now the single source of truth — no meta, no manual
stripping.
The *policy string* is the same no matter how you deliver it. If you
don't run nginx directly — e.g. you use the BunkerWeb WAF (which is
nginx under the hood) — set the identical string via BunkerWeb's
`CONTENT_SECURITY_POLICY` setting instead of `add_header`; the canonical
value is pre-filled in `ops/bunkerweb/bunkerweb.env.example`. The same
applies to Caddy, Apache, or any other front: deliver this exact header.
This addresses Finding N in docs/REVISIT-LIST.md §F.
### Required headers
Configure your frontend web server (nginx, Caddy, whatever
you use) to emit all of the following on every response:
```nginx
# HSTS — forces HTTPS for all subsequent requests
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Content type sniffing protection
add_header X-Content-Type-Options "nosniff" always;
# Referrer policy — send no referrer on navigations
add_header Referrer-Policy "no-referrer" always;
# Prevent embedding in iframes (clickjacking defense)
add_header X-Frame-Options "DENY" always;
# Permissions-Policy — microphone/geolocation/FLoC off; camera=(self)
# so the same-origin QR-login scanner (getUserMedia) keeps working
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
# Content Security Policy — the single source of truth for the
# frontend (the build emits no meta CSP). connect-src lists the
# six default Blurt RPC nodes from apps/web/src/lib/net/config.ts;
# if you run a different RPC set, edit it to match or the browser
# blocks your nodes (including failover). It lists NO price API on
# purpose: price fetching is server-side (the indexer), the browser
# never calls CoinGecko, so listing it would only leak visitor IPs.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
```
For reference, `ops/nginx/indexer.conf` and
`ops/nginx/relay.conf` already set the first four headers
on API responses. The CSP only applies to the frontend
(it has no meaning on JSON responses), so it goes on the
frontend's web server config, not the API nginx configs.
### What the CSP directives do
- `default-src 'self'` — only load resources from the
same origin by default
- `script-src 'self' 'unsafe-inline' 'unsafe-eval'
'wasm-unsafe-eval'` — JS from same origin, plus:
- `'unsafe-inline'` for the small inline scripts the
page ships (SvelteKit's hydration bootstrap and the
`?lang=` preflight hint in app.html). The previous
hash-based approach required SvelteKit to inject a
meta CSP; dropping that meta means falling back to
`'unsafe-inline'` for these. The frontend renders no
user-supplied HTML, so the inline-script attack
surface is minimal.
- `'wasm-unsafe-eval'` (and `'unsafe-eval'`) for the
in-browser cryptography: the keystore's argon2 KDF
and signing run as WebAssembly, which CSP blocks
unless one of these is present. This is non-optional
— without it, login / keystore unlock breaks. If you
confirm via testing that no dependency uses JS
`eval`/`Function`, you can drop `'unsafe-eval'` and
keep only `'wasm-unsafe-eval'` for a tighter policy.
- `style-src 'self' 'unsafe-inline'` — CSS from same
origin plus the inline styles Svelte emits (scoped
component styles and the no-JS notice in app.html)
- `img-src 'self' data: blob:` — images from same origin,
plus `data:` URIs (identicons are inline SVG data URIs)
and `blob:` URLs (decoded avatar bitmaps)
- `font-src 'self'` — fonts only from same origin (the
self-hosted Nunito subset)
- `connect-src 'self' <six Blurt RPC hosts>` — the
browser may fetch only from your own origin and the six
default Blurt RPC nodes
(`apps/web/src/lib/net/config.ts`), which it contacts
directly to read the chain and broadcast signed,
non-custodial transactions. Notes:
- There is deliberately **no price API** here. Price
data is fetched server-side by the indexer; the
browser never calls CoinGecko or any other price
host. Adding one would leak every visitor's IP to a
third party for no functional gain — a privacy
regression, not a feature.
- If you run a different RPC set, edit connect-src to
match the hosts in `config.ts`, or the browser will
block them (including failover to a backup node).
Prefer listing your specific hosts over the loose
`'self' https:` wildcard, which permits any HTTPS
host as a fetch target and defeats CSP's
exfiltration-defense value.
- `worker-src 'self' blob:` — Web Workers from same origin
(the service worker) plus `blob:` (the altcha
proof-of-work worker is built from an in-memory blob);
without `blob:`, the anti-bot challenge on registration
fails to run
- `frame-ancestors 'none'` — this page cannot be embedded
in another site's iframe (the header form of the
X-Frame-Options DENY above; it only works as a header,
which is one reason the CSP is not a meta tag)
- `base-uri 'self'` — `<base>` tag only points to same
origin
- `form-action 'self'` — form submits only to same origin
- `object-src 'none'` — no Flash, Java, or other
plugins
- `media-src 'none'`, `child-src 'none'`, `frame-src
'none'` — the app embeds no audio/video, iframes, or
nested browsing contexts, so these are locked shut as
defense-in-depth
- `manifest-src 'self'` — the PWA web-app manifest loads
only from the same origin
### HTTPS-only requirement
Serve the frontend over HTTPS only. HSTS will enforce
this on repeat visitors but doesn't help the first
request. A typical nginx pattern:
```nginx
server {
listen 80;
listen [::]:80;
server_name morphit.example.org;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name morphit.example.org;
# ... SSL config, headers above, root path to build/ ...
}
```
### Verification
From a different machine, run:
```bash
curl -sI https://morphit.example.org/ | grep -iE "(strict-transport|x-content|referrer|x-frame|permissions-policy|content-security)"
```
Expected: all six headers present. If any are missing,
fix your web server config before making the deployment
public.
### Troubleshooting: account avatars show as broken images
If newly-generated account avatars (the heart identicons on the
onboarding "your keys are ready" screen, profile chips, the avatar
menu, etc.) render as the browser's broken-image icon, and the browser
console / **Issues** panel reports a Content-Security-Policy violation
for `data:image/svg+xml` requests, your *deployed* CSP is older than
this config. Identicons are inline `data:` SVGs generated in the
browser, so the `img-src` directive must include `data:` — this config
ships `img-src 'self' data: blob:`. (The avatars are valid `data:` URIs;
the CSP is what blocks them, so rebuilding the frontend bundle alone
does NOT fix it.)
Check two things, in order:
1. **The served header.** DevTools → Network → click the HTML document
request → Response Headers → `content-security-policy` (or from a
shell: `curl -sI https://your-site/ | grep -i content-security`). If
its `img-src` is just `'self'` (no `data:`) — or there is no `img-src`
at all, in which case images fall back to `default-src` and `data:`
URIs are still blocked — your reverse proxy is serving an out-of-date
policy. *Where the CSP lives depends on what terminates TLS for you:*
on bare metal it's the `add_header Content-Security-Policy` in
`ops/nginx/web.conf` (`nginx -t && nginx -s reload`); behind BunkerWeb
it's the `CONTENT_SECURITY_POLICY` setting from
`ops/bunkerweb/bunkerweb.env.example` (restart the BunkerWeb
container). If you front the site some other way — e.g. you expose the
BunkerWeb-compose `frontend` nginx directly with the `bunkerweb`
service disabled, or use another proxy — the header is an `add_header
Content-Security-Policy` line in *that* config. Find the file with
`sudo grep -rni 'content-security-policy' <your-edge-config-dir>`
(search the response-HEADER spelling `Content-Security-Policy`, **not**
the `CONTENT_SECURITY_POLICY` env-var spelling — a grep for the latter
will miss an nginx `add_header`), bring its value in line with the
canonical one above, and reload/restart that proxy after a `nginx -t`.
2. **The service worker cache.** A previously-cached HTML response can
keep enforcing the old CSP header even after the proxy is fixed.
Hard-reload bypassing the cache, or DevTools → Application → Storage
→ **Clear site data** (which also unregisters the service worker),
then reload.
The shipped policy is correct and is guarded by
`scripts/csp-header-consistency-smoke.ts` (CI fails if `img-src` ever
loses `data:`), so a broken-avatar report is always a stale-deployment
or stale-cache symptom — never a code change.
---
## 16. Operator-account balance alerts
The indexer can watch your relay and fees accounts on-chain and
alert you when either drops below a threshold. This catches
silent drain: a @morphit-relay that runs out of BLURT stops
processing welcome bonuses, dust refills, and loyalty grants
without crashing; without alerts you'd only hear about it when
users complain.
The scanner is OFF by default. To enable, set one or both
thresholds to a non-zero value. Both accounts are monitored
independently — you can alert on the relay without alerting on
fees, or vice versa.
### Configuration
Add to the indexer's systemd unit (or `.env` if that's how you
load environment variables):
> **Tip:** these two thresholds are operator-tunable via
> `morphit.config.env` (see §23) — copy
> `morphit.config.env.example` to `morphit.config.env` and
> uncomment the relevant lines. OS-set env vars (SystemD
> `Environment=`, Docker `-e`, shell `export`) always win
> over the file, so existing env-driven deployments keep
> working unchanged.
```ini
# Alert when @morphit-relay drops below 100 BLURT. Set to 0 to
# disable monitoring for this account.
Environment="MORPHIT_INDEXER_OPERATOR_BALANCE_RELAY_THRESHOLD_BLURT=100"
# Alert when @morphit-fees drops below 10 BLURT. Fees normally
# accumulate rather than drain, so a drop below 10 typically
# indicates either an over-aggressive auto-sweep or something
# wrong with the account. Set to 0 to disable.
Environment="MORPHIT_INDEXER_OPERATOR_BALANCE_FEES_THRESHOLD_BLURT=10"
# Optional: how often the scanner polls. Default 15 min.
# Environment="MORPHIT_INDEXER_OPERATOR_BALANCE_INTERVAL_MS=900000"
# Optional: how many consecutive RPC failures trigger a
# SUSTAINED_RPC_FAILURE alert. Default 3.
# Environment="MORPHIT_INDEXER_OPERATOR_BALANCE_FAILURE_ALERT_THRESHOLD=3"
```
Reload + restart the indexer:
```sh
sudo systemctl daemon-reload
sudo systemctl restart morphit-indexer.service
```
### Choosing thresholds
The relay threshold is the most important. Rough rule: set it
to cover at least 24-48h of normal outflow (welcome bonuses +
loyalty delegations + dust refills) so you have time to notice
and top up before the relay actually empties. For a small
instance, 50-100 BLURT is typical; a busy instance might want
500+ BLURT of runway. You know your own volume better than we
do — look at 7 days of outgoing transfers from your relay on
`blocks.blurtwallet.com` to calibrate.
The fees threshold should be near zero. Fees accumulate
naturally; the only ways the balance drops are a manual sweep,
a misconfigured automated sweep, or a compromised account.
Setting fees threshold to ~10 BLURT catches all three.
### What the alerts look like
By default, alerts go to the structured logger (module
`operator-balance`). On a systemd box that means they land in
`journalctl` as JSON:
```sh
sudo journalctl -u morphit-indexer.service | grep '\[operator-balance\]'
```
Four alert kinds:
- `LOW_BALANCE` — downward crossing. Fires once per cross; as
long as balance stays below threshold, the scanner stays
quiet. Payload includes current balance, threshold, account,
and role (`relay` / `fees`).
- `RECOVERED` — upward crossing after a `LOW_BALANCE`. Lets you
confirm your top-up landed. Fires once per recovery.
- `SUSTAINED_RPC_FAILURE` — N consecutive failures to reach
Blurt for the balance check. Important because "can't check"
silently prevents alerts; you want to know your alerting is
blind.
- `SHAPE_ERROR` — balance string unparseable. Rare; usually
indicates a Blurt chain upgrade that changed response shape.
### Verifying the scanner is running
The `/v1/health?verbose=1` endpoint exposes the scanner's live
state:
```sh
curl -s http://localhost:$INDEXER_PORT/v1/health?verbose=1 \
| jq .diagnostics.operator_balances
```
Expected shape (two accounts monitored, relay currently fine,
fees not yet observed this cycle):
```json
[
{
"account": "morphit-relay",
"role": "relay",
"threshold_blurt": 100,
"below_threshold": false,
"last_observed_blurt": 245.137
},
{
"account": "morphit-fees",
"role": "fees",
"threshold_blurt": 10,
"below_threshold": null,
"last_observed_blurt": null
}
]
```
An empty array means no thresholds are configured (scanner
opted out). A `below_threshold: null` means the scanner hasn't
completed a successful observation for that account yet —
usually this resolves within one scan interval of boot.
### Routing alerts elsewhere (Discord, email, webhook)
The default sink writes structured JSON to the logger. If you
want alerts in Discord, on your phone, or in an incident-
management system, you have two options:
1. Tail the log and route externally. Simplest, no code
changes. A small sidecar reads `journalctl` output, filters
on `"module":"operator-balance"` JSON lines, and forwards
whatever payload matches your target (Discord webhook,
email relay, Matrix bot, PagerDuty API).
2. Replace the AlertSink at indexer build time. The scanner
accepts an injected sink; operators with TypeScript
comfort can fork the indexer, swap the default sink for one
that POSTs to a webhook, and deploy. Lower latency, but
requires maintaining a fork.
Option 1 is what most instances should pick.
### Canonical Matrix routing — apps/matrix-bot
For operators picking Matrix as their alert channel, the morphit
repo ships a turnkey sidecar at `apps/matrix-bot/` that implements
Option 1 above with a tier-aware classifier. Same shipping pattern
as `ops/bunkerweb/`, `ops/nginx/`, `ops/systemd/`, etc. — copy + edit
the env file, then activate with `morphit-ops matrix set <mxid>`.
**Lifecycle (`morphit-ops matrix`) — installed by default, runs only when configured:**
The matrix-bot is installed by default but stays cleanly inert until you
give it an alert username, so there is nothing to run (or watch) on an
instance that doesn't use Matrix. Manage it with one command instead of
a raw `systemctl`:
- `morphit-ops matrix set @you:matrix.org` — writes the alert MXID to
`/etc/morphit/matrix-bot.env` **and** enables + (re)starts the bot. The
MXID must be a personal `@user:server` — a `#room:server` alias is
rejected outright (routing private alerts to a public room would leak
security telemetry). Comma-separated multiple recipients are allowed
(vacation coverage). The bot also needs `MORPHIT_MATRIX_BOT_ACCESS_TOKEN`
in that file; if it's missing, the username is saved but the bot stays
stopped with a hint, rather than starting and crash-looping on the
missing token.
- `morphit-ops matrix clear` — empties the MXID and stops + disables the bot.
- `morphit-ops matrix test` — asks the running bot to DM you a clearly
labelled one-off test alert, so you can confirm delivery actually works
in a single command (no journal/classifier dance). It refuses with an
actionable hint if no username/token is set or the bot isn't running,
and it triggers the bot's OWN client over loopback rather than opening a
second Matrix client — the test DM is a real encrypted alert, identical
to a genuine one. See "Verifying end-to-end delivery" below.
- `morphit-ops matrix` (or the menu → **Check & operate → Matrix alerts**)
— shows the username, readiness, and service state, and offers to
start/stop the bot if it's out of sync.
`morphit-ops upgrade` re-checks the alert username on every upgrade and
brings the service into line automatically (enable + restart when set,
disable + stop when cleared) — so a node that gained or lost its Matrix
username between upgrades lands in the right state with no manual step.
The single source of truth is `/etc/morphit/matrix-bot.env`: the bot reads
its config there via the systemd `EnvironmentFile=` and does NOT read
`morphit.config.env`. (Fresh Ansible nodes get the service user, state
dir, unit, and env file laid down by the `matrix_bot` role; the manual
install steps below do the same by hand.)
**What it does:**
The bot tails `journalctl -u morphit-indexer -u morphit-relay -o
json --follow`, parses each line, classifies the alert into one of
three tiers (CRITICAL, WARN, INFO), and DMs the operator's MXID
over end-to-end-encrypted private Matrix chat. Three tiers,
deliberately tuned to prevent alert fatigue without losing
urgency:
- **CRITICAL** — delivered immediately, NO rate limit, NO
aggregation. Tamper-detection events (bundle hash mismatch,
pubkey mismatch), kill-switch fired, sustained RPC failure (the
alerting itself is blind), daily signup ceiling hit (active
attack signal), `INVALID_FEE_METHOD` attempts (a Memory #23 USDT-
as-listing-fee try), backup failures, AIDE integrity violations,
operator account drained to 0 BLURT (relay halted).
- **WARN** — rate-limited to one per category per hour, DM'd
individually. Low-balance crossings (above zero), witness fee
changes, stale BLURT/USD price feed, single-IP signup spikes
below the daily ceiling, federation peer down >24h, sequential-
signup pattern detected.
- **INFO** — aggregated into a single daily digest sent at 09:00
UTC. Skipped entirely on quiet days. `RECOVERED` events,
normal backup successes, federation discovery summaries.
**Two distinct Matrix addresses kept separate by design:**
- `MORPHIT_MATRIX_BOT_ALERT_MXID` — **PRIVATE** MXID for alert
DMs (`@user:server`). Bot-only; never exposed via
`/v1/instance` or any other public API. Comma-separate
multiple MXIDs for vacation coverage — the bot DMs each
recipient on every alert.
- `MORPHIT_INDEXER_OPERATOR_MATRIX_ROOM` — **PUBLIC** room alias
for user→operator contact (`#room:server`). Exposed via
`/v1/instance.operator_matrix_room`; rendered on /support,
/about-this-instance, and the site footer as a matrix.to link.
These NEVER cross-pollinate. A security alert routed to a public
room would be a privacy violation; an operator MXID exposed via
public API would leak the operator's private Matrix identity to
every API consumer. The codebase enforces this at multiple
layers: branded TypeScript types (`MatrixMxid` vs
`MatrixRoomAlias`) make compile-time confusion impossible without
an explicit cast; the bot's config refuses to start if
`MORPHIT_MATRIX_BOT_ALERT_MXID` carries a `#`-prefixed value; the
indexer's config refuses to start if
`MORPHIT_INDEXER_OPERATOR_MATRIX_ROOM` carries an `@`-prefixed
value; and adversarial smoke tests (`apps/matrix-bot/scripts/
surface-invariant-smoke.ts`) independently verify every boundary
on every CI run.
**Setup:**
```sh
# 1. Create a dedicated Matrix account for the bot (NOT your
# personal account — the bot stores a long-lived access token).
# Most Matrix clients support "Settings → Help & About →
# Access Token" or equivalent.
# 2. Create the system user.
sudo useradd --system --no-create-home --shell /usr/sbin/nologin \
--groups systemd-journal morphit-matrix-bot
sudo mkdir -p /var/lib/morphit-matrix-bot
sudo chown morphit-matrix-bot:morphit-matrix-bot \
/var/lib/morphit-matrix-bot
sudo chmod 0750 /var/lib/morphit-matrix-bot
# 3. Write /etc/morphit/matrix-bot.env (0600, root:morphit-matrix-bot):
sudo install -m 0640 -o root -g morphit-matrix-bot /dev/stdin \
/etc/morphit/matrix-bot.env <<'ENV'
MORPHIT_MATRIX_BOT_HOMESERVER=https://matrix.org
MORPHIT_MATRIX_BOT_ACCESS_TOKEN=<bot-account-access-token>
# Leave MORPHIT_MATRIX_BOT_ALERT_MXID empty here — set it in step 6 with
# `morphit-ops matrix set`, which writes it AND enables + starts the bot.
MORPHIT_MATRIX_BOT_ALERT_MXID=
# Optional:
# MORPHIT_MATRIX_BOT_JOURNALCTL_UNITS=morphit-indexer.service,morphit-relay.service
# MORPHIT_MATRIX_BOT_DIGEST_SEND_TIME_UTC=09:00
# MORPHIT_MATRIX_BOT_DRY_RUN=false
ENV
# 4. Install matrix-bot dependencies (native build for better-sqlite3).
#
# matrix-bot depends on better-sqlite3 (state persistence) and
# matrix-bot-sdk (Matrix client). better-sqlite3 compiles native
# bindings against node headers downloaded from nodejs.org during
# `npm install` — your deploy box needs:
#
# - build-essential or equivalent (gcc, make, python3)
# - outbound HTTPS to nodejs.org for the node headers
#
# Both are present on a default Ubuntu/Debian VPS once you've run
# `sudo apt install -y build-essential python3` (from §1 of
# RUN-A-MORPHIT-NODE.md). If you've sealed outbound HTTPS to a
# strict allowlist (BunkerWeb path or similar), add nodejs.org
# to the allowlist for the duration of `npm install`.
cd /opt/morphit
sudo -u morphit npm ci --workspaces --include-workspace-root \
--omit=optional --no-audit --no-fund
# Verify:
test -d /opt/morphit/node_modules/better-sqlite3/build \
|| (echo "better-sqlite3 native build did not produce build/ — see logs"; exit 1)
# 5. Install the systemd unit (do NOT `enable --now` it by hand — step 6
# does that as part of setting your alert username, so the service
# state always tracks whether a username is configured).
sudo cp /opt/morphit/ops/systemd/morphit-matrix-bot.service \
/etc/systemd/system/
sudo systemctl daemon-reload
# 6. Set your alert username — writes MORPHIT_MATRIX_BOT_ALERT_MXID into
# the env file AND enables + starts the bot. Personal @user:server
# MXID only (a #room alias is refused — it would leak private alerts).
morphit-ops matrix set @you:matrix.org
# Later: morphit-ops matrix clear empties it + stops/disables the bot
# morphit-ops matrix shows username, readiness, state
# 7. Verify.
sudo systemctl status morphit-matrix-bot
sudo journalctl -u morphit-matrix-bot --since '5 minutes ago'
# Expect: "morphit-matrix-bot starting." in the logs (recipients=N).
```
**Vacation coverage:** put a comma-separated list of MXIDs in
`MORPHIT_MATRIX_BOT_ALERT_MXID`. The bot DMs every recipient on
every alert. Your backup operator gets the same CRITICALs you do
without any extra plumbing.
**Testing the wiring:** set `MORPHIT_MATRIX_BOT_DRY_RUN=true`,
restart the unit, and the bot logs what it WOULD have sent
without actually posting to Matrix. Useful for verifying the
classifier sees your indexer/relay log lines correctly before
going live.
**Verifying end-to-end delivery (one-time check).** The quickest check is
**`morphit-ops matrix test`** — it asks the running bot to DM you a clearly
labelled test alert through its real client (token + DM creation + delivery)
and prints exactly what happened, so most operators need nothing more. The
manual runbook below *additionally* exercises the **journal → classifier**
half of the path — worth doing if you've customized the watch-list or suspect
a `_SYSTEMD_UNIT` tagging problem. DRY_RUN above proves the classifier sees
lines; both of the checks here prove a real DM actually reaches you. The bot
only acts on journal entries carrying `_SYSTEMD_UNIT=<watched-unit>`, and
`emit()` (ops/scripts/lib/emit.sh) guarantees that by writing through each
sidecar service's own `StandardOutput=journal` stream — so this test fires a
throwaway service that emits one CRITICAL through that exact path:
```bash
# a) Temporarily have the bot ALSO watch a throwaway unit, then restart.
# (MORPHIT_MATRIX_BOT_JOURNALCTL_UNITS *replaces* the default list, so
# list the defaults you rely on plus morphit-alerttest.service.)
sudo sed -i '/^MORPHIT_MATRIX_BOT_JOURNALCTL_UNITS=/d' /etc/morphit/matrix-bot.env
echo 'MORPHIT_MATRIX_BOT_JOURNALCTL_UNITS=morphit-indexer.service,morphit-relay.service,morphit-host-monitor.service,morphit-smartctl-monitor.service,morphit-fail2ban-monitor.service,morphit-mdadm-monitor.service,morphit-dmesg-monitor.service,morphit-trivy-monitor.service,morphit-postfix-monitor.service,morphit-certbot-monitor.service,morphit-apt-monitor.service,morphit-compose-monitor.service,morphit-systemd-monitor.service,morphit-journald-monitor.service,morphit-alerttest.service' | sudo tee -a /etc/morphit/matrix-bot.env >/dev/null
sudo systemctl restart morphit-matrix-bot
# b) A throwaway emit script + oneshot service (a script file avoids unit-file
# quoting pitfalls and exercises the real emit.sh path):
sudo tee /usr/local/bin/morphit-alerttest.sh >/dev/null <<'SCRIPT'
#!/bin/bash
. /opt/morphit/ops/scripts/lib/emit.sh
export MORPHIT_EMIT_MODULE=tamper MORPHIT_EMIT_TAG=morphit-alerttest
emit info bundle_hash_mismatch '{"note":"delivery test"}'
SCRIPT
sudo chmod +x /usr/local/bin/morphit-alerttest.sh
sudo tee /etc/systemd/system/morphit-alerttest.service >/dev/null <<'UNIT'
[Unit]
Description=Morphit matrix-bot delivery test
[Service]
Type=oneshot
ExecStart=/usr/local/bin/morphit-alerttest.sh
UNIT
sudo systemctl daemon-reload
sudo systemctl start morphit-alerttest.service
# -> a CRITICAL DM should arrive within seconds. The FIRST message from the
# bot account lands as a message request / invite — accept it in your client.
# c) Tear down the test (remove the unit + script + the temporary watch line):
sudo rm -f /etc/systemd/system/morphit-alerttest.service /usr/local/bin/morphit-alerttest.sh
sudo sed -i '/^MORPHIT_MATRIX_BOT_JOURNALCTL_UNITS=.*morphit-alerttest/d' /etc/morphit/matrix-bot.env
sudo systemctl daemon-reload
sudo systemctl restart morphit-matrix-bot
```
If no DM arrives, check `journalctl -u morphit-matrix-bot` for a
`failed to deliver` line (a Matrix/token error, shown verbatim); a
silent bot log plus a delivered DM is the success case (the bot only
logs delivery *failures*). Tip: **`morphit-ops matrix test`** (above) is
the one-command version of the Matrix-delivery half of this check — it
triggers the bot's own client over its loopback healthcheck endpoint, so the
DM it sends is identical to a real alert.
**Why stdout, not `systemd-cat`:** `emit()` writes to the service's own
journal stream rather than piping to `systemd-cat`, because on some
systemd/journald builds `systemd-cat`'s entries arrive with no
`_SYSTEMD_UNIT` (the stream is opened by a short-lived helper process
whose cgroup journald can't resolve) — which would make the bot's
`-u` filter silently skip every shell-sidecar alert. Routing through
the service stream guarantees the unit tag. (`emit()` still falls back
to `systemd-cat` when run outside a journal-connected service.)
**For operators who prefer email/Discord/PagerDuty instead:** the
generic Option 1 advice above still applies. matrix-bot is the
canonical sidecar but not the only one supported.
#### matrix-bot — known dependency vulnerabilities (cp138 audit)
`npm audit` reports 2 critical and several moderate CVEs that all
trace through `matrix-bot-sdk@0.7.1`'s dependency on the
deprecated `request@2.88.2` package and its transitives
(`form-data@2.3.3`, `qs`, `tough-cookie`, `uuid`). Upgrading the
SDK to its current latest `0.8.0` does NOT fix this — `0.8.0`
still depends on the same `request@^2.88.2`.
**Practical exposure on a Morphit instance is near-zero because:**
1. matrix-bot is **opt-in** — the systemd unit only does work if
`MORPHIT_MATRIX_BOT_ALERT_MXID` is set. Operators who don't
enable Matrix alerts never load the SDK into a running
process. If you're picking email or another channel for
alerts, this section doesn't apply to you.
2. matrix-bot's network direction is **outbound only**. It POSTs
to a homeserver URL that **the operator configured via env
var** (`MORPHIT_MATRIX_BOT_HOMESERVER`). It does NOT accept
inbound user URLs to fetch. The `request`-package SSRF CVE
requires user-controlled URLs; matrix-bot doesn't provide any.
3. The `form-data` unsafe-random-boundary CVE requires an
attacker-controlled multipart upload. matrix-bot doesn't
accept multipart uploads; it only emits JSON to the
homeserver.
4. The `qs` DoS and `tough-cookie` prototype-pollution CVEs
require user-supplied query strings / cookies. matrix-bot
doesn't parse any.
**What this means for operators:**
- If you don't enable matrix-bot, you can ignore these CVEs.
- If you DO enable matrix-bot, the practical risk is minimal so
long as you keep `MORPHIT_MATRIX_BOT_HOMESERVER` pointed at a
homeserver you trust (which is the design intent: it's your
Matrix homeserver, not a user's).
- An automated CVE scanner WILL flag your install. This is
expected; the scanner is right about the CVE numbers but
doesn't model matrix-bot's input surface.
**Tracked for post-launch:** cp138-R-2 in `docs/REVISIT-LIST.md`.
Two real fix options under evaluation: swap to `matrix-js-sdk`
(official Matrix SDK with a larger surface but maintained deps),
or add `npm overrides` to force-resolve transitives (needs
testing matrix-bot's actual API surface still works with
overridden versions). Neither is a pre-launch blocker.
### Host-resource monitoring sidecar — disk / memory / swap / CPU
The matrix-bot tails `morphit-indexer` + `morphit-relay` journals
by default, which surfaces application-level events. Host-level
resource exhaustion (disk full, memory critical, swap thrashing,
CPU saturated) is monitored by a separate **bash-script sidecar**
shipped at `ops/scripts/morphit-host-monitor.sh` with an
accompanying systemd timer at `ops/systemd/morphit-host-monitor.timer`.
The sidecar:
1. Runs every 5 minutes (configurable via the `.timer` file).
2. Reads `/proc/meminfo`, `df -P`, `/proc/loadavg`, `/proc/vmstat`.
3. Compares against configurable thresholds (env-tunable).
4. Emits structured JSON to journalctl via `systemd-cat -t
morphit-host-monitor`.
5. The bot picks these up automatically because
`morphit-host-monitor.service` is in the default
`MORPHIT_MATRIX_BOT_JOURNALCTL_UNITS` list.
Three tiers per resource:
| Resource | INFO threshold | WARN threshold | CRITICAL threshold |
|---|---|---|---|
| Disk usage | >70% | >85% | >95% |
| Memory usage | >70% | >85% | >95% |
| Swap usage | >25% | >50% | >75% |
| Swap thrashing (pages/sec) | — | >100 | >1000 |
| CPU saturation (load/cores) | >1.5x | >3x | >5x |
All thresholds are env-tunable. The defaults are reasonable for
a 1-4 vCPU / 2-8 GB RAM VPS — operators on heavier hardware may
relax them; operators on tighter hardware may tighten them.
**Setup:**
```sh
# 1. Create the system user.
sudo useradd --system --no-create-home --shell /usr/sbin/nologin \
morphit-host-monitor
sudo mkdir -p /var/lib/morphit-host-monitor
sudo chown morphit-host-monitor:morphit-host-monitor \
/var/lib/morphit-host-monitor
sudo chmod 0750 /var/lib/morphit-host-monitor
# 2. (Optional) Write /etc/morphit/host-monitor.env with operator-
# tuned thresholds. See ops/scripts/morphit-host-monitor.sh
# for the full list. Skip if the defaults are fine.
# 3. Install + enable the timer.
sudo cp /opt/morphit/ops/systemd/morphit-host-monitor.service \
/etc/systemd/system/
sudo cp /opt/morphit/ops/systemd/morphit-host-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-host-monitor.timer
# 4. Verify (the timer fires once 30s after boot, then every 5m).
sudo systemctl list-timers morphit-host-monitor.timer
sudo journalctl -u morphit-host-monitor --since '10 minutes ago'
# Expect: structured JSON lines on stdout when any threshold is
# breached. No output means everything is within INFO thresholds.
```
**Configurable env vars** (with defaults):
```ini
# /etc/morphit/host-monitor.env
MORPHIT_HOST_DISK_CRITICAL=95
MORPHIT_HOST_DISK_WARN=85
MORPHIT_HOST_DISK_INFO=70
MORPHIT_HOST_DISK_PATHS=/ # space-separated; add /var if separate
MORPHIT_HOST_MEM_CRITICAL=95
MORPHIT_HOST_MEM_WARN=85
MORPHIT_HOST_MEM_INFO=70
MORPHIT_HOST_SWAP_CRITICAL=75
MORPHIT_HOST_SWAP_WARN=50
MORPHIT_HOST_SWAP_INFO=25
MORPHIT_HOST_SWAP_THRASH_CRITICAL=1000 # pages/sec
MORPHIT_HOST_SWAP_THRASH_WARN=100
MORPHIT_HOST_CPU_CRITICAL=5.0 # loadavg / cores ratio
MORPHIT_HOST_CPU_WARN=3.0
MORPHIT_HOST_CPU_INFO=1.5
# All-mount sweep (cp15, skip-list extended cp22) — extends the
# operator-configured MORPHIT_HOST_DISK_PATHS check with a sweep
# of every writable mount that isn't a pseudo-filesystem.
# Catches Docker volumes, encrypted overlay mounts, runaway
# tmpfs. Set to 0 to disable; uses the same DISK_* thresholds.
# Pseudo-fs are always skipped (proc, sysfs, cgroup, devtmpfs,
# squashfs; Docker storage drivers overlay/overlay2/aufs and
# their rootless fuse.fuse-overlayfs analog; NFS server-side
# pseudo-FS rpc_pipefs and nfsd; and network mounts
# fuse.rclone/fuse.s3fs/fuse.sshfs whose `df` percentages are
# meaningless and can stall the sweep).
MORPHIT_HOST_SCAN_MOUNTS=1
```
In addition to the operator-configured paths in `MORPHIT_HOST_DISK_PATHS`,
the all-mount sweep emits three additional event types:
`mount_critical` / `mount_warn` / `mount_info` (with the same
threshold tiering as `disk_*`). Payload includes `path`,
`fstype`, `percent`, and `threshold`. This catches the
filling-bind-mount and runaway-tmpfs cases the canonical
`DISK_PATHS` doesn't cover.
**Opt-in default, same as matrix-bot.** If you don't enable the
timer, the sidecar doesn't run and no host-resource alerts fire.
Operators not using Matrix at all skip both the bot and the
sidecar.
**Adding more host-watch targets later:** the bot is open to any
unit name listed in `MORPHIT_MATRIX_BOT_JOURNALCTL_UNITS`. If
you write your own monitor (e.g. a Nagios plugin wrapper) that
emits the same `{ts, level, module, event, context}` JSON shape
via `systemd-cat -t <your-name>`, the bot will tier-route it
through the classifier. Unknown (module, event) pairs default
to INFO (digest); add an explicit matcher in
`apps/matrix-bot/src/classifier.ts` if you want CRITICAL or WARN
routing for a specific event.
### Extended monitoring sidecars — smartctl, fail2ban, mdadm
Three additional sidecars use the same emit-via-systemd-cat
pattern as the host-resource monitor. Each is opt-in (operator
must enable the timer) and is included in the bot's default
`MORPHIT_MATRIX_BOT_JOURNALCTL_UNITS` so alerts route
automatically once the timer is enabled.
#### Disk SMART health — `morphit-smartctl-monitor`
Polls `smartctl -H -A -l selftest` on every detected non-loop
block device every 6 hours. Emits structured JSON via
`systemd-cat -t morphit-smartctl-monitor`.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `smart_failed` | CRITICAL | SMART overall-health self-assessment FAILED |
| `self_test_failed` | CRITICAL | Most recent self-test reports failure |
| `temperature_critical` | CRITICAL | Disk ≥ 60°C (env: `MORPHIT_SMART_TEMP_CRITICAL`) |
| `temperature_warn` | WARN | Disk ≥ 50°C (env: `MORPHIT_SMART_TEMP_WARN`) |
| `reallocated_sectors` | WARN | `Reallocated_Sector_Ct > 0` |
| `pending_sectors` | WARN | `Current_Pending_Sector > 0` |
| `temperature_sustained_high` | WARN | SCT thermal log: lifetime max temp ≥ `TEMP_WARN + 5°C` (drive hit WARN+ at least once even if cool right now) |
| `temperature_overlimit_count` | WARN | SCT thermal log: drive firmware's over-temperature counter is non-zero |
| `smartctl_unavailable` | INFO | smartmontools not installed |
The SCT thermal-log events (`temperature_sustained_high` and
`temperature_overlimit_count`) come from `smartctl -l scttempsts`,
which the drive itself maintains. They surface trends the
instantaneous temperature check can't see: a drive that briefly
spiked above threshold between samples, and a drive whose own
firmware has flagged sustained thermal stress. Drives that
don't support SCT thermal logging are silently skipped (no
event emitted).
Setup:
```sh
# 1. Install smartmontools.
sudo apt install -y smartmontools
# 2. (Optional) Operator-tuned thresholds.
sudo install -m 0644 -o root -g root /dev/stdin \
/etc/morphit/smartctl-monitor.env <<'ENV'
MORPHIT_SMART_TEMP_CRITICAL=55 # tighter for hot data centres
MORPHIT_SMART_TEMP_WARN=45
ENV
# 3. Install + enable.
sudo cp /opt/morphit/ops/systemd/morphit-smartctl-monitor.service \
/opt/morphit/ops/systemd/morphit-smartctl-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-smartctl-monitor.timer
```
Caveats: SMART monitoring is most useful on bare-metal hosts.
On most VPS providers the disks are virtualized and smartctl
reports either nothing or the host's own disks, so the alerts
may be uninformative. Useful for self-hosted dedicated
hardware.
#### fail2ban observability — `morphit-fail2ban-monitor`
Polls `fail2ban-client status` every 5 minutes. Alerts on
daemon-down (meaning brute-force is NOT being blocked) and
ban-count spikes (meaning attack in progress). Delta-tracks
total bans across runs for ban-rate detection.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `daemon_unreachable` | CRITICAL | fail2ban-client cannot reach the daemon |
| `jail_critical_ban_count` | CRITICAL | currently-banned ≥ `MORPHIT_FAIL2BAN_BAN_CRITICAL` (50) |
| `jail_high_ban_count` | WARN | currently-banned ≥ `MORPHIT_FAIL2BAN_BAN_WARN` (15) |
| `jail_ban_rate_warn` | WARN | bans/hour rate ≥ 100 (delta-tracked) |
| `fail2ban_unavailable` | INFO | fail2ban-client not in PATH |
Per-jail overrides via env vars
`MORPHIT_FAIL2BAN_<UPPERCASE-JAIL>_CRITICAL` and `_WARN` — e.g.
a busy SSH jail might want `MORPHIT_FAIL2BAN_SSHD_CRITICAL=100`
while a quiet postfix jail uses the default 50.
Setup:
```sh
# 1. fail2ban itself must already be running (§34 covers install).
# 2. (Optional) operator-tuned thresholds.
sudo install -m 0644 -o root -g root /dev/stdin \
/etc/morphit/fail2ban-monitor.env <<'ENV'
MORPHIT_FAIL2BAN_BAN_CRITICAL=50
MORPHIT_FAIL2BAN_BAN_WARN=15
MORPHIT_FAIL2BAN_SSHD_CRITICAL=100 # SSH jail is allowed to be loud
ENV
# 3. State dir for ban-rate delta tracking.
sudo mkdir -p /var/lib/morphit-fail2ban-monitor
sudo chmod 0750 /var/lib/morphit-fail2ban-monitor
# 4. Install + enable.
sudo cp /opt/morphit/ops/systemd/morphit-fail2ban-monitor.service \
/opt/morphit/ops/systemd/morphit-fail2ban-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-fail2ban-monitor.timer
```
#### Linux software RAID — `morphit-mdadm-monitor`
Reads `/proc/mdstat` every 15 minutes. No package install
needed — `/proc/mdstat` is in the kernel. Safe to enable
defensively on any host: exits silently if no md arrays exist.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `array_failed` | CRITICAL | Array no longer functional (all devices gone) |
| `array_degraded` | CRITICAL | One or more devices failed/missing |
| `array_resyncing` | INFO | Array rebuilding (normal after disk replacement) |
Setup:
```sh
sudo cp /opt/morphit/ops/systemd/morphit-mdadm-monitor.service \
/opt/morphit/ops/systemd/morphit-mdadm-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-mdadm-monitor.timer
```
No service user setup needed — the unit uses `DynamicUser=true`
since `/proc/mdstat` is world-readable.
#### Kernel-log monitor — `morphit-dmesg-monitor`
Scans the kernel ring buffer (`dmesg`) every 5 minutes for
events the host-resource sidecar can't see: the host-monitor
sees memory pressure *building*; the dmesg-monitor sees the
consequences when it broke.
State is cursor-based at `/var/lib/morphit-dmesg-monitor/last-cursor`
so successive runs don't re-alert on old events.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `oom_kill` | CRITICAL | Kernel killed a process to free memory |
| `kernel_oops` | CRITICAL | Kernel detected an internal error |
| `kernel_panic` | CRITICAL | Kernel panicked (host may be unstable) |
| `hardware_error` | CRITICAL | MCE / EDAC / ATA / I/O error |
| `segfault_in_morphit` | CRITICAL | A morphit-related process segfaulted |
| `segfault_other` | WARN | Some other process segfaulted |
| `fd_exhausted` | WARN | Fork failed (out of FDs/PIDs) |
| `dmesg_unreadable` | INFO | dmesg not readable (service must run as root) |
Each alert includes the raw kernel-log line (first 200 chars)
so you can pattern-match in `journalctl` for context.
Setup:
```sh
# 1. State dir.
sudo mkdir -p /var/lib/morphit-dmesg-monitor
sudo chmod 0750 /var/lib/morphit-dmesg-monitor
# 2. Install + enable. (Service runs as root because
# kernel.dmesg_restrict=1 is the default since Debian 12.
# Hardening uses CapabilityBoundingSet=CAP_SYSLOG to confine it.)
sudo cp /opt/morphit/ops/systemd/morphit-dmesg-monitor.service \
/opt/morphit/ops/systemd/morphit-dmesg-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-dmesg-monitor.timer
```
#### Docker image CVE rescan — `morphit-trivy-monitor`
Daily scan of running Docker images (typically just BunkerWeb,
when deployed) for CRITICAL + HIGH severity CVEs disclosed since
deploy. Without this monitor, an operator wouldn't know they
were running a vulnerable BunkerWeb until they happened to read
a CVE advisory and remembered they had it deployed.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `image_critical_vulns` | CRITICAL | Image has ≥ `MORPHIT_TRIVY_CRITICAL_THRESHOLD` (default 1) CRITICAL CVEs |
| `image_high_vulns` | WARN | Image has ≥ `MORPHIT_TRIVY_HIGH_THRESHOLD` (default 5) HIGH CVEs |
| `image_scan_failed` | WARN | trivy returned no output for an image |
| `image_scan_clean` | INFO | No actionable findings (daily digest) |
| `trivy_unavailable` | INFO | trivy not installed |
Setup:
```sh
# 1. Install trivy from the Aqua Security apt repo.
sudo install -d -m 0755 /etc/apt/keyrings
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key \
| sudo tee /etc/apt/keyrings/trivy.asc > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/trivy.asc] \
https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/trivy.list > /dev/null
sudo apt update && sudo apt install -y trivy jq
# 2. (Optional) operator-tuned thresholds.
sudo install -m 0644 -o root -g root /dev/stdin \
/etc/morphit/trivy-monitor.env <<'ENV'
MORPHIT_TRIVY_CRITICAL_THRESHOLD=1
MORPHIT_TRIVY_HIGH_THRESHOLD=5
ENV
# 3. Install + enable.
sudo cp /opt/morphit/ops/systemd/morphit-trivy-monitor.service \
/opt/morphit/ops/systemd/morphit-trivy-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-trivy-monitor.timer
```
Outbound network requirements: trivy needs to pull its CVE DB
from `ghcr.io` (with `mirror.gcr.io` as fallback). Add both to
your outbound firewall allowlist if you have one.
Note: most CVEs in base images are not exploitable in the way
you're using the container. When trivy alerts on a CVE that
doesn't apply to your setup, add it to `/etc/morphit/.trivyignore`
to silence future alerts for that CVE ID specifically.
#### Postfix queue monitor — `morphit-postfix-monitor`
Watches the postfix mail queue depth + oldest-message age every
15 minutes. Solves a critical observability gap: if email
alerting silently fails (smarthost credentials rotated, TLS
bumped, network down), emails pile up in the postfix queue and
the operator hears nothing. This sidecar makes "alerts aren't
arriving" itself become an alert.
Useful only if you use postfix as your alerting smarthost (per
the §37.14 alerting role). Skip if you use a different alerting
mechanism.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `queue_critical` | CRITICAL | Queue depth ≥ 100 OR oldest message > 120 min |
| `queue_warn` | WARN | Queue depth ≥ 25 OR oldest message > 30 min |
| `queue_clean` | INFO | Queue empty or below thresholds |
| `postfix_unavailable` | INFO | postqueue not in PATH |
All thresholds env-tunable in `/etc/morphit/postfix-monitor.env`.
Setup:
```sh
# Postfix itself must be installed already (per §37.14).
# Verify with:
which postqueue || sudo apt install -y postfix
# Install + enable the monitor.
sudo cp /opt/morphit/ops/systemd/morphit-postfix-monitor.service \
/opt/morphit/ops/systemd/morphit-postfix-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-postfix-monitor.timer
```
#### TLS cert expiry + renewal-stall detector — `morphit-certbot-monitor`
Daily check of TLS cert expiry combined with a **renewal-stall
detector** that catches the killer pattern: cert is about to
expire AND certbot has not had a successful renewal in N days.
A cert renewing fine 6 months ago can silently start failing for
weeks before it actually expires; this sidecar finds that gap.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `cert_expiry_critical` | CRITICAL | Cert expires in ≤ 7 days |
| `cert_expiry_warn` | WARN | Cert expires in ≤ 30 days |
| `renewal_stalled` | CRITICAL | Cert expiring AND last successful renewal > 14 days ago |
| `certbot_unavailable` | INFO | openssl or `/etc/letsencrypt/live/` missing |
All thresholds env-tunable. Reads `/var/log/letsencrypt/letsencrypt.log`
for the "Renewal was successful" line timestamps; falls back
gracefully if the log is rotated or unreadable.
Setup:
```sh
# certbot itself must be installed (per §35 TLS role).
# Verify with:
test -d /etc/letsencrypt/live || echo "certbot not configured yet"
# (Optional) operator-tuned thresholds.
sudo install -m 0644 -o root -g root /dev/stdin \
/etc/morphit/certbot-monitor.env <<'ENV'
MORPHIT_CERTBOT_EXPIRY_CRITICAL_DAYS=7
MORPHIT_CERTBOT_EXPIRY_WARN_DAYS=30
MORPHIT_CERTBOT_RENEWAL_STALL_DAYS=14
ENV
# Install + enable.
sudo cp /opt/morphit/ops/systemd/morphit-certbot-monitor.service \
/opt/morphit/ops/systemd/morphit-certbot-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-certbot-monitor.timer
```
#### Pending security updates monitor — `morphit-apt-monitor`
Daily count of pending security updates. Surfaces the same
information the motd shows but operators stop reading after the
first month — this routes them through the same alert channel
as everything else. Debian/Ubuntu only.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `security_updates_critical` | CRITICAL | Security updates pending ≥ 10 |
| `security_updates_warn` | WARN | Security updates pending ≥ 1 |
| `updates_pending_info` | INFO | Non-security updates only (daily digest) |
| `apt_unavailable` | INFO | apt not in PATH |
Setup:
```sh
# (Optional) operator-tuned thresholds.
sudo install -m 0644 -o root -g root /dev/stdin \
/etc/morphit/apt-monitor.env <<'ENV'
MORPHIT_APT_SECURITY_CRITICAL=10
MORPHIT_APT_SECURITY_WARN=1
ENV
# Install + enable.
sudo cp /opt/morphit/ops/systemd/morphit-apt-monitor.service \
/opt/morphit/ops/systemd/morphit-apt-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-apt-monitor.timer
```
Note: the sidecar runs `apt-get update` itself before counting,
so it always reports against fresh package lists. Outbound
network requirements: same as your apt install (e.g.
archive.ubuntu.com).
#### Docker Compose service health monitor — `morphit-compose-monitor`
Watches Docker Compose service health-check status + restart
counts every 5 minutes. Catches three patterns: services
reporting `health: unhealthy` (canonical compose signal),
services in state `exited` when they should be running, and
services in restart loops (high `RestartCount` over short time).
Most useful with the BunkerWeb deploy path (§32). Useless on
bare-metal-only — the sidecar exits cleanly with an INFO event
in that case.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `service_unhealthy` | CRITICAL | docker compose ps reports `Health: unhealthy` |
| `service_exited` | CRITICAL | Service stopped unexpectedly |
| `service_restart_loop` | WARN | RestartCount ≥ 5 (env-tunable) |
| `docker_unavailable` | INFO | Docker / Compose v2 plugin missing |
Setup:
```sh
# (Optional) operator-tuned threshold + project list.
sudo install -m 0644 -o root -g root /dev/stdin \
/etc/morphit/compose-monitor.env <<'ENV'
MORPHIT_COMPOSE_RESTART_THRESHOLD=5
MORPHIT_COMPOSE_PROJECTS=/opt/morphit/ops/bunkerweb
ENV
# Install + enable.
sudo cp /opt/morphit/ops/systemd/morphit-compose-monitor.service \
/opt/morphit/ops/systemd/morphit-compose-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-compose-monitor.timer
```
For multiple compose stacks: `MORPHIT_COMPOSE_PROJECTS=` accepts
a space-separated list of project directories.
#### systemd unit-health monitor — `morphit-systemd-monitor`
Watches `morphit-*` units (plus any in `MORPHIT_SYSTEMD_WATCH`)
for **failed state** and **high restart counts**. This closes
a gap journalctl-based alerting can't cover: a unit that fails
to even start emits no journal output for the bot to route, so
a failed-start would be silently invisible without this sidecar.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `unit_failed` | CRITICAL | `systemctl is-failed` returns true for a watched unit |
| `unit_restart_loop` | WARN | `NRestarts ≥ 10` (env-tunable) on a still-running unit |
| `unit_missing` | WARN | A unit named in `MORPHIT_SYSTEMD_WATCH` does not exist (config drift) |
| `systemctl_unavailable` | INFO | systemctl not in PATH |
Setup:
```sh
# (Optional) tuning + extra units to watch.
sudo install -m 0644 -o root -g root /dev/stdin \
/etc/morphit/systemd-monitor.env <<'ENV'
MORPHIT_SYSTEMD_RESTART_THRESHOLD=10
MORPHIT_SYSTEMD_WATCH="postgres@16-main.service docker.service"
ENV
sudo cp /opt/morphit/ops/systemd/morphit-systemd-monitor.service \
/opt/morphit/ops/systemd/morphit-systemd-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-systemd-monitor.timer
```
#### Journal disk-usage monitor — `morphit-journald-monitor`
Daily check of journald's own disk usage + time span covered.
Catches the "journal silently grew to 8 GB over six months"
pattern: without `SystemMaxUse=` in `/etc/systemd/journald.conf`,
the journal can fill the disk; operators usually find out only
when the disk is full.
Events emitted:
| Event | Tier | Trigger |
|---|---|---|
| `journal_size_critical` | CRITICAL | Journal disk usage > 4 GB |
| `journal_size_warn` | WARN | > 1 GB |
| `journal_rotation_stale` | WARN | Span > 90 days AND > 500 MB (config-drift indicator) |
| `journalctl_unavailable` | INFO | journalctl not in PATH |
Setup:
```sh
sudo cp /opt/morphit/ops/systemd/morphit-journald-monitor.service \
/opt/morphit/ops/systemd/morphit-journald-monitor.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-journald-monitor.timer
```
Recommended companion: set `SystemMaxUse=1G` (or your preferred
cap) in `/etc/systemd/journald.conf` and
`sudo systemctl restart systemd-journald` so the disk-usage
ceiling is enforced.
### Deploying all sidecars at once via Ansible
The repository ships an Ansible playbook at `ops/ansible/` that
wraps all the above into opt-in roles. Set `enable_*: true` for
the sidecars you want in `group_vars/all.yml`, populate the
`vault_matrix_bot_access_token` in `group_vars/vault.yml` if
using Matrix, and:
```sh
cd /opt/morphit/ops/ansible
ansible-playbook -i inventory/hosts.yml playbook.yml --tags monitors
```
The `monitors` tag runs only the sidecar roles, leaving the rest
of the deploy untouched — convenient for adding monitoring to an
already-deployed instance.
## 17. Relay origin allowlist — protecting your instance from billing drift
The relay pays the ~100 BLURT account-creation fee on every
`account_create` op it broadcasts. Without origin
enforcement, **any web page, `curl` invocation, or script
anywhere on the internet can POST to your relay's
`/v1/account/create` and you'll spend ~100 BLURT for it.** Three
concrete scenarios:
1. **Community mirror misconfiguration.** Another operator
forks Morphit, forgets to update `MORPHIT_RELAY_ORIGIN` in
their frontend build, and their users' signups hit your
relay. You silently fund their community.
2. **Hostile frontend.** Someone deliberately runs a frontend
that points at your relay to drain your funds.
3. **`curl` spam.** A signup-bot that doesn't care about
browser CORS can POST directly.
The relay defends against all three with an **origin
allowlist** — a server-side check on `/v1/account/create` that
rejects any request whose `Origin` header isn't on your
allowlist.
### What goes in the allowlist?
The `Origin` header reflects **where the browser loaded the page
from** — not where your backend services live. Your frontend's
public URL is what the browser sends, regardless of whether the
backend is on the same server, a different VPS, a CDN, or
localhost. Two common topologies:
**Colocated (one host, one public hostname — the recommended
default).** Your frontend, relay, and indexer all run on
`vps.example.com`, fronted by nginx. Users browse to
`https://morphit.example.com/` and the browser sends `Origin:
https://morphit.example.com` on POSTs to the relay at
`https://morphit.example.com/relay/v1/account/create`. This is
still a "same-origin POST" and browsers include the Origin
header for POSTs even when same-origin.
In this topology — which is what the frontend defaults to (see
§14 "Recommended single-hostname layout") — the relay and
indexer do **not** need their own DNS entries. The frontend
hits `/relay/*`, `/v1/*`, and `/rss/*` as relative paths on the
same origin, and nginx reverse-proxies them to loopback. Your
allowlist needs only the one public hostname:
```
MORPHIT_RELAY_ALLOWED_ORIGINS=https://morphit.example.com
```
Note: "localhost" does NOT appear as the Origin even though the
nginx proxy internally forwards to `127.0.0.1:8080`. The browser
only sees the public URL. Use `http://localhost:5173` only for
local dev where you literally load the page from localhost.
**Split (relay on separate subdomain).** Your relay is on
`relay.morphit.example.com` while the frontend is on
`morphit.example.com`. This requires the frontend to be built
with `MORPHIT_RELAY_ORIGIN=https://relay.morphit.example.com`
overriding the default (see §14 "Split topology"). Users browse
the frontend; browser sends `Origin: https://morphit.example.com`
when POSTing to the relay on `relay.morphit.example.com`. This is
a cross-origin POST. The allowlist entry is still the FRONTEND's
origin, not the relay's:
```
MORPHIT_RELAY_ALLOWED_ORIGINS=https://morphit.example.com
```
**Multiple frontends against one relay.** If you run several
mirrors (.onion, i2p, a .com) all pointing at the same relay,
list them all:
```
MORPHIT_RELAY_ALLOWED_ORIGINS=https://morphit.example.com,http://abc123xyz.onion,http://b32addr.i2p
```
### Configuration
Set the allowed origins in your relay systemd unit:
```ini
# Comma-separated list of exact-match origins (scheme + host +
# optional port, no path, no wildcards). Every frontend that
# should be allowed to create accounts via this relay goes here.
Environment="MORPHIT_RELAY_ALLOWED_ORIGINS=https://morphit.example.com,https://mirror.example.com"
```
The relay validates this at startup: an empty allowlist makes it
**refuse to start** (it throws `MORPHIT_RELAY_ALLOWED_ORIGINS must
list at least one origin`) rather than run wide open — a fail-closed
default.
Reload + restart:
```sh
sudo systemctl daemon-reload
sudo systemctl restart morphit-relay.service
```
### Matching rules (read carefully)
- **Exact match.** `https://morphit.example.com` does NOT match
`https://www.morphit.example.com` or
`https://morphit.example.com:8443`. Add each variant
explicitly if you serve the same frontend under multiple
hostnames.
- **Scheme matters.** `http://` and `https://` are distinct
origins. Production instances should only list `https://`.
- **Port matters.** If you serve on a non-default port, include
it. For testing, `http://localhost:5173` is the usual Vite
dev-server origin.
- **No wildcards.** The allowlist is a plain `Set<string>` and
`includes` is exact.
### Behavior
- Request with `Origin` in the allowlist → proceeds normally.
- Request with `Origin` present but not in the allowlist → 403
with `{ code: "origin_not_allowed" }`.
- Request with no `Origin` header at all → 403 with
`{ code: "origin_required" }`. Modern browsers always send
`Origin` on cross-origin POSTs, so a missing header almost
always means a non-browser client.
Read-only endpoints (`/v1/account/availability`, `/v1/health`)
are NOT gated by the allowlist. Availability is expected to be
called from curl by operators debugging; health is a liveness
probe.
### Reading the logs
Rejections are logged under module `relay-origin`, deduplicated
per (code, origin) pair within a 5-minute window so a sustained
curl-storm doesn't flood the journal. Grep for the module name
when debugging:
```sh
sudo journalctl -u morphit-relay.service | grep '\[relay-origin\]'
```
Two log lines to expect:
- **`rejected_disallowed_origin`** (WARN) — someone's browser
sent an Origin you haven't listed. The log payload includes
the rejected origin AND your configured allowlist so you can
see the fix in one line. This is the signal that matters
during setup or when moving to a new hostname.
- **`rejected_missing_origin`** (INFO) — a request arrived with
no Origin header. Almost always a non-browser client (curl,
bot, or a custom script). Usually ignorable; frequent hits
suggest someone is probing your endpoint.
### What if legitimate users see origin_required?
First, check `Origin` is actually being sent by their browser.
Ad-blockers and privacy extensions occasionally strip it, but
that's rare on POST. If a user consistently can't sign up and
reports that error, they're probably using an ancient browser
or an aggressive privacy tool — the relay is correctly
refusing to spend funds on an unverifiable request. Point them
at a standard browser.
If your OWN users are getting origin_not_allowed, it means your
frontend is serving from an origin you didn't list. Common
causes:
- You added a new hostname or moved to a CDN and forgot to add
the new origin.
- You're testing from `http://localhost:5173` without adding
it. Add it to `MORPHIT_RELAY_ALLOWED_ORIGINS` for the
duration of testing; remove for production if localhost
doesn't belong there.
- Your production frontend is on `https://example.com` but
the allowlist says `https://www.example.com`. Add both or
redirect one canonically.
### Limits of this defense
An attacker can still forge the `Origin` header with `curl` or
a custom HTTP client. The allowlist raises the friction from
"paste a URL into the browser" to "write a script that spoofs
Origin," which is material for most classes of casual abuse —
but it is NOT a rate-limit or a bot-defense. Your existing
per-IP rate limiters (hourly + daily) do that job. The origin
allowlist specifically closes the "other frontends billing my
relay" gap, nothing more.
If you need cryptographic assurance that a request came from
your own frontend, that requires a shared-secret scheme
(frontend embeds a token at build time, relay validates the
token). That's a larger design change not yet built.
## 18. Signup-drain prevention — the full defense stack
The relay's `/v1/account/create` endpoint pays the ~100 BLURT
account-creation fee inline (from `@morphit-relay`'s liquid BLURT)
to create each new Blurt account (see ADR-0010 §4, as amended), so
each successful signup spends ~100 BLURT of your relay's balance.
Without defenses, a third-party operator who forges the
`Origin` header (server-side scripts can) could attribute THEIR
users' registrations to YOUR relay, draining your relay's BLURT and
forcing you to pause signups or keep topping up out-of-cycle
(BLURT-expensive). The signup-drain defense is a layer cake;
each layer is cheap, additive, and tunable. None alone is
sufficient; together they make drains **bounded, detectable fast,
and reversible**.
### Layer 1: Kill-switch
Instant halt. When something goes wrong, flip this first.
```ini
# Default: true (signups enabled). Flip to false to halt ALL
# account creation immediately.
Environment="MORPHIT_RELAY_SIGNUP_ENABLED=false"
```
Reload + restart:
```sh
sudo systemctl daemon-reload
sudo systemctl restart morphit-relay.service
```
While `SIGNUP_ENABLED=false`, both `/v1/account/invite` and
`/v1/account/create` return `503` with `code:
"signups_disabled"`. The frontend shows a "signups temporarily
unavailable, please try another Morphit mirror" message.
### Layer 2: Global daily ceiling
Hard cap on successful signups per UTC day. Bounds worst-case
spend to `ceiling` signups per day; in BLURT terms that's
`ceiling × account_creation_fee` BLURT at risk (where the fee
is whatever the chain's `account_creation_fee`
witness-parameter is at claim time, typically ~100 BLURT). Reset
at UTC midnight.
```ini
# Default 50/day. Start conservative at launch — raise as you
# observe real traffic.
Environment="MORPHIT_RELAY_SIGNUP_DAILY_CEILING=50"
```
When the ceiling is hit, one structured log line fires (module
`signup-ceiling`):
```sh
sudo journalctl -u morphit-relay.service | grep '\[signup-ceiling\]'
```
Expect `ceiling_reached` at level `error`. Further signups that
day return `code: "daily_ceiling_reached"` with a `resets_at`
timestamp so the frontend can tell the user when to try again.
**When to raise:** you're regularly hitting the ceiling during
normal operation (not during attacks). Start at 50/day; at
steady state, you want the ceiling to be `2×` your observed
peak legitimate day. Raising mid-attack is exactly wrong — the
ceiling is your budget-backstop.
### Layer 3: Per-IP spacing
Two mechanisms stack per IP address:
1. A hard daily cap (`MORPHIT_RELAY_CREATE_RATE_PER_DAY`,
default 2).
2. A minimum gap between this IP's signups
(`MORPHIT_RELAY_CREATE_SPACING_MINUTES`, default 60). Even
if the IP has daily capacity left, a second signup within
the gap is rejected.
```ini
Environment="MORPHIT_RELAY_CREATE_RATE_PER_DAY=2"
Environment="MORPHIT_RELAY_CREATE_SPACING_MINUTES=60"
```
Rejected with `code: "spacing_cooldown"` and a
`retry_after_minutes` field. The frontend shows:
> You recently created an account. Please wait N more minute(s)
> before creating another.
This layer targets the "family of four on one Wi-Fi" UX case:
legitimate, allowed, but spaced out. It also defeats the
"cheap VPS, one IP, 50 signups/day" variety of attacker.
### Layer 4: Signed invite tokens (two-step signup)
Account creation is not a single request — it's two. First the
client POSTs to `/v1/account/invite`. If the relay approves,
it returns a short-lived HMAC-signed invite bound to the
client's IP hash. The client then POSTs to
`/v1/account/create` with the invite as part of the body. The
relay verifies the signature, expiry, IP binding, and marks
the invite used before broadcasting to the chain.
Why two steps:
- The signing secret is server-only. An attacker who downloads
the frontend bundle gets nothing.
- The invite endpoint is where expensive checks live (rate
limit + PoW). The create endpoint stays focused on
signature verification + chain op.
- Short TTL (10 min default) makes stockpiling impractical.
- Single-use via in-memory nonce map prevents replay.
```ini
# Optional: persistent HMAC secret. If unset, the relay
# generates a random 32-byte secret at boot (ephemeral — invites
# in flight don't survive a restart, which is acceptable since
# TTL is only 10 min).
Environment="MORPHIT_RELAY_INVITE_HMAC_SECRET=your-32-byte-random-secret"
```
Most operators should leave the secret unset. The ephemeral
default is safe and avoids the "secret file on disk" problem.
### Layer 5: Altcha proof-of-work (3rd attempt per IP per day)
Altcha is self-hosted client-side PoW. The browser runs ~1-2
seconds of SHA-256 work in a Web Worker before the invite is
granted. **No Cloudflare, no third-party calls, no tracking.**
Triggered on the 3rd+ invite request per IP per UTC day. The
first two attempts are frictionless. Normal users never see
Altcha unless they're retrying a lot after failures.
```ini
# 3 = altcha fires on the 3rd attempt. Lower = more friction
# for legit retry-after-failure users; higher = less defense
# against attackers who bypass per-IP limits.
Environment="MORPHIT_RELAY_ALTCHA_TRIGGER_COUNT=3"
# PoW difficulty. Default 2_000_000 → ~1s on a modern phone.
# Old Android on slow CPUs: ~2-3s.
Environment="MORPHIT_RELAY_ALTCHA_MAXNUMBER=2000000"
# Optional persistent HMAC secret, same semantics as invite.
Environment="MORPHIT_RELAY_ALTCHA_HMAC_SECRET=your-32-byte-random-secret"
```
The Altcha solver is **lazy-loaded** on the frontend: only
users who hit the 3rd attempt download the PoW code. All other
users pay zero bandwidth for this layer.
Frontend UX during solving:
> Verifying you're human… → Verified ✓ → Claiming…
### Layer 6: Anomaly-aware LOW_BALANCE alerts
Ties it all together. When the indexer's operator-balance
scanner fires a `LOW_BALANCE` alert on the relay account, it
probes the relay's `/v1/health?verbose=1` for current signup
stats and decides whether to append a kill-switch
recommendation.
Wire the probe in the indexer's env:
```ini
# Indexer systemd unit: URL to the relay's health endpoint.
# For colocated deployments (relay on same host as indexer),
# this hits loopback — no public exposure needed.
Environment="MORPHIT_INDEXER_RELAY_HEALTH_URL=http://127.0.0.1:8080/v1/health?verbose=1"
```
With the probe wired, a `LOW_BALANCE` alert payload gains a
`signup_anomaly` sub-object. Two anomaly conditions recommend
the kill-switch:
1. Current UTC hour's signup count ≥ 1/3 of the daily ceiling
(rate would exhaust capacity in under 3 hours).
2. Current hour ≥ 2× today's peak hour AND ≥ 5 signups (spike
relative to the day's normal).
When either triggers, the alert includes the recommendation
text:
> "Consider setting MORPHIT_RELAY_SIGNUP_ENABLED=false while
> you investigate."
If neither triggers, the alert reports normal volume.
### Layer 7: High-value name policy
The first six layers bound the COUNT of signups an attacker can
extract. Layer 7 reduces the VALUE of each signup to the
attacker by refusing to register names that look like obvious
squatter targets. A determined attacker who exhausts the daily
ceiling now walks away with names like `usr-noob-2026` or
`bobtrades` — names with low resale value — instead of
`bitcoin`, `nike`, or `acct001`.
Six categories are recognized (in priority order):
1. **`short_name`** — name length ≤ threshold (default 4). Short
names on Graphene-lineage chains are status symbols and
sell for $50-$500 on secondary markets.
2. **`all_numeric`** — letter prefix followed entirely by digits
and dashes (e.g., `a000000`). Pure enumeration value, no
real-user appeal.
3. **`dictionary_brand`** — exact match against a curated list
of ~100 well-known brand and crypto names (apple, google,
bitcoin, nike, binance, etc.). Brands defensively buy these;
squatters know it.
4. **`leet_brand`** — l33t-substituted brand (`m0nero`,
`b1tcoin`). De-leet table: `0→o, 1→i, 3→e, 4→a, 5→s, 7→t,
@→a`. Catches the lowest-effort obfuscation; not exhaustive.
5. **`common_dictionary`** — common English words with resale
value (`money`, `wallet`, `news`, `media`, `premium`).
Conservative list — focuses on nouns with clear identity-
marketing value.
6. **`numeric_suffix`** — short prefix (≤4 chars) followed by
EXACTLY 3 digits (`usr001`, `bob-001`, `acct999`). Enumerator
signature. Intentionally narrow — 4-digit suffixes are
year-suffix forms (`bob-1990`, `crypto-noob-2026`) which
are legitimate user names; the cross-signup detector
(Layer 8) catches actual enumeration patterns instead.
Configuration:
```ini
# strict (default) — block all six categories
# moderate — block only enumeration patterns (numeric / numeric_suffix);
# allow brand/dictionary names through (only pick this if
# you've decided your other defenses make brand-squatting
# unprofitable)
# off — disable Layer 7 entirely (NOT recommended)
Environment="MORPHIT_RELAY_HIGHVALUE_NAME_POLICY=strict"
# Names this length or shorter trip the short_name category.
# Default 4 — blocks 3- and 4-char names. Lower to 3 to allow
# 4-char. Lower to 2 to disable short-name (still keeps brand/
# dictionary detection).
Environment="MORPHIT_RELAY_HIGHVALUE_SHORT_NAME_THRESHOLD=4"
```
When Layer 7 fires, the relay logs a structured
`highvalue_name_rejected` event with `name`, `classification`,
`policy` fields. The user receives `400 name_high_value` with
the localized error message:
> "That name is reserved for legitimate-claim review. Try a
> longer or more personal name. If you have a legitimate claim
> to this exact name, contact the operator."
**Honest limits.** Layer 7 catches obvious squatter targets;
it doesn't (and shouldn't) catch every valuable name. A novel
phrase, a niche meme, or a legitimate but generic name that
isn't on the dictionary list will still pass. Operators
periodically reviewing recent registrations may want to add
to `RESERVED_NAMES` (in `apps/relay/src/policy/name.ts`) when
they spot patterns. False positives are also possible — a real
user named `nike` who uses Blurt would be rejected by Layer 7.
For those, the legitimate path is to direct-broadcast their
account creation to the chain (not via the relay), or to
contact an operator with a moderate policy.
### Layer 8: Sequential signup pattern detection
Layer 7 examines a single name in isolation. Layer 8 watches
for ENUMERATION patterns ACROSS recent successful signups
within the same /24 (IPv4) or /64 (IPv6) bucket. A pattern of
`account001`, `account002`, `account003` from the same bucket
is the signature of an automated drainer; Layer 8 refuses the
3rd one even though Layer 7 lets each individual name pass on
shape alone (long prefix).
Three patterns are detected:
1. **`sequential_numeric_suffix`** — same prefix, differing
numeric suffix (`acct001`, `acct002`, ...).
2. **`sequential_alpha_suffix`** — same prefix, differing
single-letter suffix (`accta`, `acctb`, `acctc`).
3. **`sequential_close_similarity`** — same long prefix even
when the tail isn't strictly numeric/alpha (`userfoo01`,
`userfoo02`).
State is in-memory, per-bucket, with a rolling window. A relay
restart resets the state — acceptable, since an attacker
mid-restart loses their accumulated history too.
Configuration:
```ini
# Enable / disable Layer 8 entirely. Default true. Set false
# only if you run a service that legitimately creates batched
# accounts.
Environment="MORPHIT_RELAY_SEQUENTIAL_DETECTOR_ENABLED=true"
# Rolling window in milliseconds. Default 1 hour. An attacker
# who paces signups beyond the window bypasses Layer 8 (but is
# still bounded by global daily ceiling, per-IP spacing, and
# Altcha PoW).
Environment="MORPHIT_RELAY_SEQUENTIAL_WINDOW_MS=3600000"
# Number of prior matching signups before the next is rejected.
# Default 2 — meaning the 3rd sequential signup is the one
# blocked. Higher = more permissive (allows operators who
# legitimately batch-create some accounts to do so before
# hitting the limit).
Environment="MORPHIT_RELAY_SEQUENTIAL_THRESHOLD=2"
# Minimum prefix length for the close-similarity check. Names
# sharing fewer characters than this aren't considered similar.
# Default 3.
Environment="MORPHIT_RELAY_SEQUENTIAL_MIN_PREFIX=3"
```
When Layer 8 fires, the relay logs a structured
`sequential_pattern_rejected` event with `name`, `bucketKey`,
`reason`, and `matched` (the prior names that triggered the
pattern). The user receives `429 name_sequential_pattern`:
> "Recent account creations from your network have followed a
> sequential pattern that suggests automation. Try a name that
> doesn't share a prefix with recent signups, or wait an hour
> and retry."
**Honest limits.** Per-bucket isolation means an attacker who
controls multiple /24 ranges (a residential-proxy pool with
diverse upstream) can sustain enumeration — but each bucket
only allows `threshold` matching signups before being cut off.
At default settings (threshold=2, window=1h, daily ceiling=50),
defeating Layer 8 requires ~25 distinct /24 buckets per day
just to fully consume the ceiling — well within reach for a
serious attacker but a meaningful capital cost. False
positives can occur for legitimate batch workflows (e.g., a
company onboarding several staff accounts simultaneously);
those operators can tune the threshold up or temporarily
disable.
### Tuning playbook during a suspected attack
1. **Flip the kill-switch.** `MORPHIT_RELAY_SIGNUP_ENABLED=false`
and restart. This stops the bleeding immediately with zero
risk.
2. **Check the anomaly alert.** Was signup volume actually
abnormal? If yes, you're under attack. If no, the
low-balance alert was organic — top up and re-enable.
3. **Examine recent signups.** `blocks.blurtwallet.com` → your
relay account → recent `account_create` ops. Look for
similar naming patterns, sequential creation times, no
follow-up on-chain activity after creation. Those are
attacker signatures.
4. **Inspect Layer 7 + 8 rejection logs.** Search the relay's
structured logs for `highvalue_name_rejected` and
`sequential_pattern_rejected` events. Their volume tells
you what the attacker is TRYING to register; their
`bucketKey` field tells you which /24s are involved. If
you see thousands of such events from a single /16, you
have intelligence the attacker can't see.
5. **Lower the ceiling** if you decide to re-enable but want
tighter guardrails: `MORPHIT_RELAY_SIGNUP_DAILY_CEILING=20`
(or whatever feels safe).
6. **Lower the altcha trigger count** to `2` if you think the
attacker is using 2 invites per IP to stay under the PoW
gate.
7. **Tighten Layer 7 + 8 if you've been running on `moderate`
policy.** Switch `MORPHIT_RELAY_HIGHVALUE_NAME_POLICY=strict`
if it isn't already. Lower the sequential threshold to 1
(`MORPHIT_RELAY_SEQUENTIAL_THRESHOLD=1`) so the SECOND
sequential signup is the one blocked, not the third.
8. **Re-enable.** `MORPHIT_RELAY_SIGNUP_ENABLED=true`, restart.
9. **Watch for 24-48h.** Anomaly alerts will tell you if the
attacker is still at it.
### Honest limits of this defense
A determined attacker with a large residential-proxy pool AND
the willingness to solve PoW challenges at scale can still
drain up to the daily ceiling. What you DON'T have:
- **Unlimited signups**: ceiling caps it.
- **Zero-friction drain**: per-IP + Altcha forces cost.
- **Undetected drain**: the anomaly detector raises the flag.
- **Unstoppable drain**: the kill-switch is one env var flip
away.
- **Squatter-resellable names**: Layer 7 + 8 mean an attacker
who DOES drain the ceiling walks away with names that have
little resale value (long-prefix non-brand names that
weren't in any sequential pattern).
What you DO have is exposure capped at `(ceiling × fee)` per
day. At 50/day × ~$0.20/fee that's a **$10/day maximum
financial loss** for an attacker who fully defeats every layer.
The economic argument before Layers 7 + 8 was "an attacker
might burn $10/day to acquire $X in resaleable names + the
satisfaction of the disruption." With Layers 7 + 8 in place,
the resaleable-names component drops sharply because the
attacker cannot get short, brand, or sequential names at all.
This shifts the attacker's cost-benefit: they're paying $10/day
for low-value names, against an operator whose response
bandwidth (kill-switch flip, ceiling adjustment) is ~30 seconds.
The attack becomes uneconomic for any motive other than pure
disruption.
For a stronger defense requiring cryptographic assurance (not
just friction + detection), you'd need a shared-secret scheme
where the frontend embeds a server-issued token at build time
and the relay validates it. That's future work; the current
stack gets you "cannot be catastrophically harmed," which is
the operational target.
## 19. Chat anti-spam (Finding H) — operational reference
The chat handler enforces a three-layer defense against
unsolicited message floods. Each layer is a runtime gate
that doesn't require operator intervention; this section
exists so you can diagnose user reports like "why was my
message rejected" or "why does this person need to pay to
message me."
**Layer 1 — block list.** A user blocks another via a
`morphit_block_v1` custom_json op. The `blocks` table
records the (blocker, blocked) pair. The chat handler
rejects `recipient_blocked_sender` before the INSERT.
Blocks are public on-chain (anyone scraping Blurt sees
them) but the UI never surfaces "you are blocked by @X" to
the blocked party — that would turn a defensive signal
into a provocation.
**Layer 2 — stranger-fee admission.** First-contact
messages between two accounts that have never exchanged
require either (a) a prior admitted message in either
direction, (b) a paid `morphit_stranger_fee_v1` op carrying
a BLURT transfer to @morphit-fees with memo binding
`morphit-stranger:<recipient>`. The memo binding prevents a
single paid transfer from admitting conversations with
multiple peers. The base fee is **5 BLURT** (~$0.01 at
current BLURT prices), and it **escalates by doubling** for
rapid repeat stranger-messaging: 1× for the first stranger
fee inside a rolling 5-minute window, 2× for the second, 4×
for the third, … capping at 128× (640 BLURT) from the 8th
onward. A single genuine first-contact stays cheap; a burst
of unsolicited first-contacts to many peers gets expensive
fast. The BLURT base and the doubling schedule are fixed in
indexer code — **operators cannot configure them** (this is
intentional: a lax operator lowering the fee would undercut
the anti-spam economics across the whole ecosystem).
**Layer 3 — rate limits.** Two caps on "recipient has not
yet replied" conversations: fan-in (≤20 unique never-
replied senders per recipient per rolling 24h) and
per-pair no-reply cap (≤50 messages from one sender to a
non-replying recipient, ever). A single reply from the
recipient lifts both caps for the pair forever.
**Layer order.** Block check runs first (blocked senders
shouldn't push legit toward the fan-in cap), then
admission, then rate limits. A blocked sender's stranger-
fee payment is still accepted by the stranger_fees
handler (fees and admission are decoupled for auditability),
but their chat messages still won't reach the recipient.
**Diagnosing support tickets.** If a user says "my message
was rejected":
- Query `blocks` — has anyone blocked this sender-recipient
pair?
- Query `chat_messages` — is there prior exchange between
them? If no, does `stranger_fees` have a row for
(sender, recipient)?
- Check the conversation message count for the sender's
24h fan-in and per-pair accumulation.
None of these are operator-adjustable — the gates are
protocol-level. If a user is legitimately stuck, direct
them to (a) unblock if blocked, (b) pay the stranger fee
if first contact, (c) wait for the recipient to reply to
lift the rate caps.
### Chat head-block fast path (sub-6s delivery) — ADR-0048
New chat messages appear in an open conversation within a
few seconds instead of waiting ~4560s for Blurt's
last-irreversible block. A separate scanner tails the chain
HEAD and streams new chat messages over SSE; the normal
poller (which only applies irreversible blocks) is
unchanged and stays the sole source of truth for chat
history and everything else.
**Always on — there is no off switch** (v1.7.0, ADR-0051). The
scanner never writes the database, so the worst a broken fast
path can do is fail to make things fast; there is nothing to
protect you from, and nobody prefers slow. The old
`MORPHIT_INDEXER_CHAT_FASTPATH_ENABLED` was **removed, not
renamed** — if it is still in your env file it is now inert and
can be deleted.
One knob remains, in `ops/env/indexer.env`:
```
MORPHIT_INDEXER_FASTPATH_INTERVAL_MS=2000 # default 2000
```
Raise it to reduce head-poll traffic on a straining node; lower
it for tighter latency.
- It **never writes the database** — read-only (block feed +
a block-list lookup) plus in-process SSE. A crash in it
cannot affect the durable poller or your data.
- It is **chat-only** and enforces the **block list** (a
blocked sender's message is never streamed, failing closed
on any DB error). It does not re-run the stranger-fee /
rate-limit gates — the durable pass still enforces those
for stored history, so a stranger's message can flash up
live for a few seconds before it fails to persist. That is
bounded and intentional; the block list, the one gate whose
bypass would matter, is always enforced.
- If a head block is later orphaned by a fork, a message
shown live simply never lands in durable history. Fine for
chat; this is exactly why orders and fees are **not** on
this path.
- Extra RPC: roughly one head-poll per interval (~30/min at
2s) plus a block fetch per new block (~20/min) — about a 2×
increase in block-feed calls. Raise the interval to reduce
load, lower it for tighter latency.
**No off switch.** Fast chat can't be disabled — the old
`..._ENABLED` flag was removed in v1.7.0 (ADR-0051). The one
knob is `MORPHIT_INDEXER_FASTPATH_INTERVAL_MS` above: raise it
to shed head-poll RPC load, lower it for tighter latency. (The
scanner never writes the DB, so there's nothing to protect
against by turning it off — a broken fast path can only fail
to make things fast.)
**Checking status.** The simplest check is the node-health
view — `morphit-ops health` (main menu item #13) — which
shows a **Fast path:** line right below the price feeds:
`keeping up @ head block N (M delivered)` (or `— K block(s)
behind head`) when it's live and current, `lagging — K blocks
behind head` if it has fallen behind, `tailing — head not
established yet` just after a restart, or `status unavailable
(older indexer build)` against a pre-fast-path indexer. There
is no "off" state — it's always on. Under the hood that comes
from an operator-only top-level `fastpath` block on
`/v1/health` (`{running, scannedHead, emitted, lastError,
lastErrorAt}` — renamed from `chat_fastpath`, and the
`enabled` field dropped, in v1.7.0), gated on the same
`X-Morphit-Local-Health` header the public edge strips — the
same gate as the per-source `price_feeds` block, so a public
caller can't see it. (It is deliberately NOT in the
`?verbose=1` diagnostics block, so the node-health view —
which doesn't pass `verbose=1` — always sees it.)
**Upgrade note.** Fast chat is on for every instance (no off
switch since v1.7.0), so a normal `morphit-ops upgrade` carries
it forward automatically — confirm afterward with the **Fast
path:** line in `morphit-ops health`. The matching client-side
dedupe ships in the same release, so both halves deploy
together — no partial-deploy window. A browser tab still on a
pre-upgrade frontend could briefly show a message twice until
it reloads; harmless and self-healing.
## 20. Attestation phase transition (Finding I)
Finding I mitigates a sybil-attack path on BTC/XMR fee
attestation by requiring each attestor to meet loyalty
(≥100 BLURT cumulative fees paid) or age (≥30 days on
Blurt chain) thresholds. The gate runs in two phases
controlled by the `MORPHIT_INDEXER_ATTESTATION_PHASE`
env var.
**Default is `'launch'` (OR gate).** An attestor qualifies
by meeting **either** loyalty OR age. This is the
ecosystem-bootstrap mode: a lower bar for early adopters
that still blocks same-day-farmed sock accounts — each
would need to accumulate ≥100 BLURT in paid fees OR wait a
month.
**Transition to `'steady'` (AND gate).** Attestor must
meet **both** loyalty AND age. Makes sustained sybil
abuse negative-ROI — an attacker must wait 30 days AND
accumulate ≥100 BLURT of paid fees for every sock puppet,
a real time-and-capital cost per identity that makes a
self-attestation farm uneconomic.
### When to flip
Whichever comes first of:
- **90 days** after the ADR-0011 activation (the calendar
trigger guarantees migration eventually happens
regardless of traffic).
- **500 accounts** on the chain that already meet BOTH
gates (the traffic trigger lets us migrate sooner if
the ecosystem grows faster than the calendar).
### How to check if the traffic trigger is met
```sh
psql "$MORPHIT_INDEXER_DATABASE_URL" <<'SQL'
SELECT COUNT(*) AS eligible_for_steady
FROM accounts a
LEFT JOIN account_loyalty al ON al.account = a.name
WHERE a.created_block_time <= NOW() - INTERVAL '30 days'
AND COALESCE(al.cumulative_blurt_paid, 0) >= 100;
SQL
```
If the result is ≥500, the traffic trigger is met.
### How to flip
Update the indexer's environment:
```sh
# systemd example:
sudo systemctl edit morphit-indexer.service
# Set:
# Environment=MORPHIT_INDEXER_ATTESTATION_PHASE=steady
sudo systemctl restart morphit-indexer.service
```
Or in a docker-compose deployment, update the `environment`
block and restart the container. **No redeploy required** —
just an env var flip + process restart.
### Verifying the flip landed
```sh
# After restart, verify the new phase is active by
# checking a known-ineligible account's eligibility response
# and confirming the AND gate fires:
curl -s "http://localhost:PORT/v1/attestor-eligibility/<account>" | jq .phase
# Should report "steady"
```
### Don't flip before the trigger fires
Flipping prematurely locks legitimate early attestors out
of the feeAttest handler (returning `attestor_young_account`
or `attestor_insufficient_loyalty`) and prevents any BTC/XMR
orders from reaching `verified_by_attestation`. The whole
attestation path stalls. Wait for the trigger.
### Don't refuse to flip after the trigger fires
Leaving `launch` permanently means the AND gate never
activates, and any patient attacker can still sybil-attest
their own orders with two ≥30-day-old accounts. The OR gate
is a bootstrap mode, not a permanent posture.
## 20b. Schema v39 upgrade note — chat read-state is re-keyed, and the indexer cannot be rolled back over it
`morphit-ops upgrade` applies this automatically at indexer
start-up; there is nothing for you to run by hand and no new
environment variable.
**What it does.** `chat_read_state` gains an `order_permlink`
column and its primary key becomes
`(reader_account, peer_account, order_permlink)`. Chat read
receipts are now per *discussion* — one per (peer, order) —
rather than one per peer, so reading one conversation with
someone no longer marks every other conversation with that
same person as read.
**Existing rows are safe.** Every row written before this
migration was, by definition, an acknowledgement covering the
whole peer, so the column's `DEFAULT '*'` backfills them with
exactly that meaning. Nobody's inbox lights up unread on
upgrade day.
**Table size.** One row per (reader, peer) — small. The
`ADD PRIMARY KEY` rebuilds that index while holding an
`ACCESS EXCLUSIVE` lock on the table. On any realistic
instance this is well under a second, and only chat read
receipts touch the table.
> ### ⚠ DOWNGRADE HAZARD
>
> An indexer older than v39 writes read receipts with
> `ON CONFLICT (reader_account, peer_account)`. That
> constraint no longer exists after this migration, and
> Postgres will reject the statement with *"no unique or
> exclusion constraint matching the ON CONFLICT
> specification"*.
>
> **Rolling the indexer back to a pre-v39 build after this
> migration has run will break chat read receipts** until you
> roll forward again. Nothing else is affected: messages,
> orders, and the orderbook are untouched. If you must roll
> back and stay there, restore the database from the backup
> taken before the upgrade.
## 21. Schema v17 upgrade note — brief orderbook sequential-scan window
When deploying an indexer build that includes schema-v17,
the migration runs a `DROP INDEX` + `CREATE INDEX` on the
`orders_verified_live_idx` → `orders_live_established_idx`
replacement. Postgres partial-index predicates are immutable,
so this is the only way to widen the filter to include
`verified_by_attestation`.
**Expected behavior:** the migration runs inside a
transaction. For the few seconds between DROP and
CREATE, orderbook queries fall back to a sequential
scan. Not using `CREATE INDEX CONCURRENTLY` because the
migration system wraps each migration in a transaction
for atomicity — CONCURRENTLY can't run inside a
transaction.
**At Morphit's scale** (indexer-sized, not exchange-scale)
the recreate completes in seconds. Operators running
unusually large orders tables (e.g. after months of
accumulation without VACUUM) should be aware that they'll
see a brief write lock + a few-second orderbook latency
spike during the deploy window.
**If the migration takes long enough to matter**, the
workaround is to run the equivalent SQL manually with
`CREATE INDEX CONCURRENTLY` BEFORE starting the indexer
with the new code, then comment out the v17 migration
registration in `migrations.ts` for that deploy only.
This is an expert-operator escape hatch; most deploys
don't need it.
## 22. Choosing Blurt RPC endpoints
Morphit components call Blurt RPC endpoints in three
places:
| Component | Purpose | Config |
|---|---|---|
| Frontend | User signs + queries from their browser | `DEFAULT_RPC_ENDPOINTS` in `apps/web/src/lib/net/config.ts`, overridable in Settings per-user |
| Relay | Broadcasts user-signed ops, pays per-op BLURT fees | `MORPHIT_RELAY_BLURT_RPC` env var (comma-separated) |
| Indexer | Follows the block stream | (indexer's own env, see ADR-0010 deployment notes) |
Each component has its own rotation logic (latency-based
pick with cooldown on failure). The default lists are
seeded from witnesses who were reliably serving a public
RPC endpoint at Morphit launch time.
### When to revisit your endpoint list
- A witness retires their public RPC (node goes offline
permanently).
- A new high-quality RPC node becomes available in your
region (latency win).
- Your relay's logs show one endpoint consistently
timing out despite being up from your monitoring
perspective (e.g. the endpoint has started
geo-filtering or CGNAT-filtering).
- After a Blurt network upgrade — older nodes may lag
in shipping the new version and return stale data.
### How to update your indexer's RPC list
The indexer reads `MORPHIT_INDEXER_RPC_ENDPOINTS` from
`morphit.env` at startup. Three ways to change it, from
easiest to most low-level:
1. **`morphit-ops edit`** (recommended for an existing instance).
The wizard's edit flow now includes "Blurt RPC endpoints"
in its menu of editable sections. It validates the list
(https-only, dedup, well-formed URLs), backs up the
previous version of `morphit.env` to a timestamped
`.bak-` file, and writes atomically. Available only
when `morphit.env` exists at the repo root — operators
who deploy via Docker/SystemD `Environment=` directives
instead won't see this option.
(Don't remember the subcommand? Run bare `npx morphit-ops`
on a terminal — cp186 — and pick **Edit settings → Blurt RPC
endpoints** from the menu. The menu lists every action with
a one-line description; non-interactive/piped runs still
print help as before.)
The **`morphit-ops init`** wizard (fresh setup) also
prompts for the RPC list as its 19th step
(cp137 F-2 — pre-cp137 this prompt was missing and
operators silently got hardcoded defaults). Press
Enter to accept the bundled defaults, or paste a
comma-separated list of your preferred endpoints.
**Save-as-you-go / resume.** The wizard records your
NON-SECRET answers as you go to `~/.morphit-init-progress.json`
(written mode 0600). If a run is interrupted, re-running
`morphit-ops init` detects that file and offers to resume from
it — reusing your saved answers and re-asking only the two
things deliberately NEVER written there: the **database
connection string** (it embeds the DB password) and the
**relay's active key**. Those re-prompts exist because a
private key / DB password sitting in a predictable home-dir
file after an interrupted run would be a dangling secret; the
progress type structurally omits both and `saveProgress` hard-
strips them as defense-in-depth (regression-guarded by
`init-progress-smoke`). The file is deleted automatically once
setup completes successfully, or when you choose "start fresh".
2. **Edit `morphit.env` by hand.** Find the
`MORPHIT_INDEXER_RPC_ENDPOINTS=` line and replace the
comma-separated value. Same atomic-replace discipline
applies if you care about durability — write to
`morphit.env.tmp`, then `mv morphit.env.tmp morphit.env`
so a crash mid-edit doesn't leave a half-written file.
3. **Override via the OS environment.** SystemD units
can use `Environment="MORPHIT_INDEXER_RPC_ENDPOINTS=..."`,
Docker compose can use the `environment:` block. OS
env wins over `morphit.env` (see operator-config
package's loading order), so this is the right path
for deployment automation.
**After ANY change**: the indexer must restart to pick up the
new values. `morphit-ops edit` and `alt-address` now OFFER to do
this for you (press Enter at the prompt); for a hand-edit of the
env file, restart it yourself:
```sh
sudo systemctl restart morphit-indexer
```
Watch journald for the `starting` log line confirming the
new endpoint count, then for the first successful block
poll. If the indexer fails to start, the log will tell
you which endpoint refused — fix that one and restart.
### How to evaluate candidate nodes
Morphit's built-in rotator handles runtime health-based
selection. What it DOESN'T do is help you pick which
endpoints to seed the list with in the first place. For
that, a community-run tool is the fastest path:
**@nalexadre's Blurt Nodes Checker** — a library that
periodically probes every known Blurt RPC endpoint and
scores them on availability, response time, block-lag,
and Nexus compatibility. The checker drives the live
node list BeBlurt uses in its frontend.
- Article (2026-04): [Blurt Nodes Checker 2.2.0: from
smart scoring to adaptive
monitoring](https://blurt.blog/blurt-101010/@nalexadre/blurt-nodes-checker-from-smart-scoring-to-adaptive-monitoring-1777039040219)
- Source: <https://gitlab.com/beblurt/blurt-nodes-checker>
(GPLv3+)
- Published as `@beblurt/blurt-nodes-checker` on npm
Morphit **does not** ship this library as a dependency
— our frontend bundle is deliberately lean, and our
rotator handles runtime failover without needing an
RxJS-based monitor inline. But as an operator picking
which endpoints to put in your config, the checker's
live scoring output is a better signal than guessing.
You can run the checker yourself (it's a small Node
package) or just read its most recent report on the
Blurt Discord / in nalexadre's blog posts, which
publish periodic roundups.
### Updating your endpoint list
**Frontend default list:** edit
`apps/web/src/lib/net/config.ts` → `DEFAULT_RPC_ENDPOINTS`
and rebuild. This is the list new users get on first
visit; existing users who customized their list in
Settings keep their own list.
**Relay:** update `MORPHIT_RELAY_BLURT_RPC` in the
relay's systemd/docker-compose env file and restart the
relay. Every entry must be `https://`.
**Indexer:** update per ADR-0010 deployment notes and
restart. The indexer is the most-sensitive component —
pick endpoints that are known to stay caught up with
the chain head.
### Common pitfalls
- **Don't list only one endpoint.** A single RPC is a
single point of failure. Three endpoints is a
reasonable minimum, five is robust.
- **Don't list only Cloudflare-fronted endpoints.** Part
of Morphit's resilience promise is routing around
single-vendor outages. Mix origins.
- **Don't list endpoints whose operators you distrust.**
An RPC node can return falsified responses (e.g. lie
about an account's balance) in ways the caller can't
always detect. Witnesses are a reasonable trust
heuristic — they have skin in the game via their
witness position.
- **Don't hardcode endpoint lists in downstream forks
without updating them.** The default list was accurate
at ship time; a fork shipping stale defaults degrades
the user experience of that fork's users.
## 23. The morphit.config.env file — operator-tunable knobs in one place
Morphit reads ~80 environment variables across the indexer
and relay. Most of them encode deployment specifics
(database URL, RPC endpoints, account names, log
destinations) that deployment automation manages — those
stay in your SystemD unit, Docker compose, or
`.env.production`. But a small set of variables are
operationally interesting *after* the service is up: the
BLURT/USD price fallback when the live feed is down, the
registration kill-switch you'd flip during a spam wave,
alert thresholds you'd tune as the instance grows.
For those, there's `morphit.config.env`: a single file
at the repo root with the small set of operator-tunable
variables, with prose comments explaining each.
### Where the file lives
`morphit.config.env` at the repo root. A template is
shipped as `morphit.config.env.example` — copy it,
uncomment the lines you want to set, restart.
If your deployment runs from somewhere other than the repo
root, set `MORPHIT_OPERATOR_CONFIG_FILE` to the absolute
path of the file. The indexer and relay both honor this
env var.
### Precedence
Anything in the OS environment wins over this file. So:
- `export MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR=0.003`
in your SystemD `Environment=` directive → wins
- `MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR=0.003` in
`morphit.config.env` → loses to the SystemD setting,
applied if SystemD doesn't set it
This means existing deployments that rely entirely on
env-var config keep working unchanged. The file is purely
additive.
### What's in the file
The allowlist accepts **29 keys** — the complete set is
listed (commented, ready to uncomment) in
`morphit.config.env.example` and enforced by
`@morphit/operator-config`. The handful you'd actually
reach for *after* launch are detailed below with their
purpose; the remainder (per-instance branding, SEO
overrides, alt-network addresses, the operator tag) are
the values `morphit-ops init` writes for you and are rarely
hand-edited. Anything NOT on the allowlist causes a clear
"you can't set that here" hard error at boot — so an
operator who pastes the wrong file (e.g., a deployment
`.env` with DATABASE_URL in it) gets a clean rejection
rather than silent corruption.
**Pricing — survives a Coingecko outage.**
`MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR` (default `0.001`,
renamed from the old `MORPHIT_INDEXER_BLURT_PRICE_USD` — if you
still have the old name set, switch it) is the absolute price
floor used when the live feed is unreachable. Live feeds always
win when reachable; this only kicks in during an outage. Update
it during prolonged outages so the indexer's emergency fallback
matches reality. See §13 for the full price-feed runbook.
**Registration kill-switch.** `MORPHIT_RELAY_SIGNUP_ENABLED`
(default `true`). Flip to `false` to immediately stop new
account onboarding while existing users continue normally.
Use during active spam-account waves, maintenance, or
suspected drain attacks (§7, §18).
**Listing fee.** `MORPHIT_INDEXER_FEE_BASE_BLURT` (default
`125`) is the BLURT base fee per order listing. It targets the
canonical ~12.5¢ USD-equivalent (`LISTING_FEE_USD.blurt` in
`@morphit/asset-registry`); at BLURT ≈ $0.002 that's about 6062
BLURT. **As of cp372 this base is chain-pinned and auto-tracked**
(see §40.3a): the canonical floor comes from the most recent
`morphit_release_v1` `treasury.blurt.base`, resolved chain-pin →
env exactly like the BTC/XMR amounts, so this env var is now your
**Plan-B fallback / local override** — used only on a fresh node
that hasn't seen a treasury-bearing release op yet, or if you
deliberately want a different local floor. Rarely worth
changing on a single instance — listings posted on your
instance with non-standard fees look unusual to other
operators indexing the chain. Federation uniformity is
the value; deviation is a cost.
The fee model is BLURT-native (per ADR-0011 amendment,
Part 90). Earlier versions of this doc referenced
`MORPHIT_INDEXER_FEE_BASE_USD`, which was simplified out
of the codebase along with the USD-oracle dependency at
verification time. Operators who want to track USD
parity should adjust `_FEE_BASE_BLURT` periodically as
BLURT's price drifts.
**Featured-slot bid floor.**
`MORPHIT_INDEXER_FEATURE_FEE_BLURT_PER_HOUR` (default `50`).
BLURT cost per hour of featured-slot time. Raise to make
featured slots more exclusive; lower to encourage more
bidding.
**Verbose health.** `MORPHIT_INDEXER_VERBOSE_HEALTH`
(default `false`). Gates the fuller `/v1/health?verbose=1`
diagnostics — operator balances, explorer-pool circuit
state, drift/disagreement — that aren't useful to public
consumers and that NEW-9-8 keeps off by default. Toggle on
while debugging an operational issue; revert when done.
Note: per-source price-feed status in `morphit-ops health`
does NOT need this — it's operator-only and automatic (see
the verbose-mode note earlier in this section).
**Operator-balance alert thresholds.**
`MORPHIT_INDEXER_OPERATOR_BALANCE_RELAY_THRESHOLD_BLURT`
and `..._FEES_THRESHOLD_BLURT` (defaults `0` = disabled).
Set to non-zero values to receive LOW_BALANCE alerts when
your service accounts drop below the threshold. See §16
for the alert pipeline.
### What's NOT in the file (and why)
**Deployment specifics — DATABASE_URL, RPC_ENDPOINTS,
CHAIN_ID, OFFICIAL_POSTING_PUBKEY, FEE_RECIPIENT account
names.** A wrong value here corrupts state or takes the
service down. We want deployment automation (which gets
these right) to be the only path that sets them; a stray
paste of `morphit.config.env` mustn't be able to overwrite
them.
**Spam-economic constants — STRANGER_FEE_BASE_USD,
STRANGER_FEE_MAX_DOUBLINGS, STRANGER_FEE_WINDOW_MINUTES,
chat layer-3 caps.** These are deliberately uniform across
the federation. If a single operator could lower them,
their users get spammed and the whole federation's
reputation suffers. Changing them requires upstream code
changes and federation-wide consensus, not a local config
edit.
**Log destinations, listen ports, CORS allowlists.**
Operationally important but not "tunable in a hurry" —
they're set once at deployment and don't change in
response to live conditions. Keep them in your SystemD/
Docker config alongside the other deployment specifics.
### Example workflow — the live BLURT/USD feed is down, BLURT price moved
You notice the live feed (Coingecko) has been down for hours and another
exchange shows BLURT trading 30% higher than your last
known price.
1. SSH to the indexer box.
2. `cd /opt/morphit` (or wherever your repo lives).
3. If `morphit.config.env` doesn't exist yet:
`cp morphit.config.env.example morphit.config.env`.
4. Edit `morphit.config.env`:
```
MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR=0.0026
```
5. `sudo systemctl restart morphit-indexer.service`.
6. The indexer logs will show:
`[operator-config] loaded /opt/morphit/morphit.config.env (1 applied, 0 skipped — env wins)`.
7. When Coingecko comes back up, the live feed takes over
automatically. The fallback only matters during the
outage.
You don't need to revert step 4 when Coingecko returns —
the live feed wins whenever it's reachable. Leaving the
fallback at the more accurate value just means future
outages start from a better baseline.
### Example workflow — spam-signup wave
Health endpoint shows abnormal signup velocity. You want
to stop the bleeding while you investigate.
1. Edit `morphit.config.env`:
```
MORPHIT_RELAY_SIGNUP_ENABLED=false
```
2. `sudo systemctl restart morphit-relay.service`.
3. New signups now return a clear "registration
temporarily disabled" message. Existing users keep
working.
4. Investigate (§7, §18).
5. When safe, flip back to `true` and restart again.
### Example workflow — operator with strict deployment automation
You don't want to edit any config files on production
boxes; everything goes through Ansible/Terraform/whatever.
That's fine — the file is genuinely optional. Your
existing pipeline that sets `MORPHIT_*` env vars in the
SystemD unit continues to work exactly as before.
`morphit.config.env` is for operators who DO want a
human-edited file; if that's not you, ignore it.
### Verifying the file took effect
After restart, the indexer/relay logs include a line at
boot indicating what the loader did:
```
[operator-config] loaded /opt/morphit/morphit.config.env (2 applied, 1 skipped — env wins)
[operator-config] skipped (already in env): MORPHIT_INDEXER_PRICE_FEED_STATIC_FLOOR
```
The `skipped` list is the key signal: if you edited a
value but it didn't take effect, it's because the OS env
already had it set. Either remove the env-var setting (in
SystemD/Docker) or accept that the env wins.
If the file is missing entirely:
```
[operator-config] no morphit.config.env found — using OS environment only
```
This is fine — it's the expected output for env-var-only
deployments.
### Adding new keys to the allowlist
Every key in the file is checked against an allowlist in
`packages/operator-config/src/index.ts`. Adding a new key
requires editing that allowlist (and the
`morphit.config.env.example` template, and this section
of the runbook). The deliberate friction is the point —
each new operator-tunable lever is a new responsibility
to document and a new federation-uniformity question to
think about. Keep the surface small.
```sh
# Service control (systemd)
sudo systemctl status morphit-relay.service
sudo systemctl restart morphit-relay.service
sudo journalctl -u morphit-relay.service -f
# Relay log grep (structured logger — module prefix in brackets)
sudo journalctl -u morphit-relay.service | grep '\[relay-drainer\]'
sudo journalctl -u morphit-indexer.service | grep '\[witness-fee\]'
# JSON log mode (set MORPHIT_LOG_FORMAT=json in the service unit
# if you want to pipe logs into loki/vector/etc)
# sudo journalctl -u morphit-indexer.service | jq 'select(.module=="witness-fee")'
# On-chain checks
# open blocks.blurtwallet.com in browser; search for @morphit-relay
# Queue peek
psql "$MORPHIT_INDEXER_DATABASE_URL" -c "
SELECT kind, recipient, amount_blurt, reason, error_count, broadcast_at
FROM relay_pending_transfers
ORDER BY created_at DESC
LIMIT 20;"
# Check the relay's liquid BLURT balance (signups need fee + margin)
curl -s http://localhost:PORT/v1/health?verbose=1 | jq .blurt_balance
# Live price source status
curl -s http://localhost:PORT/v1/health?verbose=1 | jq .diagnostics.price
# Finding I — check attestor eligibility for an account
curl -s "http://localhost:PORT/v1/attestor-eligibility/<account>" | jq
# Finding I — count accounts eligible to attest under 'steady' phase
# (for planning the launch → steady transition)
psql "$MORPHIT_INDEXER_DATABASE_URL" -c \
"SELECT COUNT(*) AS eligible_for_steady
FROM accounts a
LEFT JOIN account_loyalty al ON al.account = a.name
WHERE a.created_block_time <= NOW() - INTERVAL '30 days'
AND COALESCE(al.cumulative_blurt_paid, 0) >= 100;"
# Finding H layer-2 — check if a chat pair is admitted
curl -s "http://localhost:PORT/v1/chat-admission/<me>/<peer>" | jq
# Finding H layer-2 — count stranger-fee payments collected
# (useful for tracking spam-prevention revenue + abuse patterns)
psql "$MORPHIT_INDEXER_DATABASE_URL" -c \
"SELECT COUNT(*) AS fee_payments,
SUM(amount_blurt)::text AS total_blurt,
MIN(paid_at) AS first_payment,
MAX(paid_at) AS most_recent
FROM stranger_fees;"
# Fee-status filter regression guard (run in CI or pre-deploy)
cd /opt/morphit/apps/indexer && npx tsx scripts/fee-status-filter-lint.ts
```
### Alt-network addresses (Tor / Lokinet / I2P / Nostr / ENS) — the `alt-address` wizard
> **Tor onion is automatic (cp378).** Every instance gets a **basic v3 `.onion` by default** — privacy is the first priority. The setup wizard **generates one in the background** while you answer the other steps (a plain non-vanity address, instant — you never wait), writes `MORPHIT_INSTANCE_TOR_ADDRESS` so the **footer pill + the `Onion-Location` auto-redirect** light up automatically, and saves the Tor hidden-service key files to a `tor-hidden-service/` directory (`hs_ed25519_secret_key` 0600, `hs_ed25519_public_key`, `hostname`). The wizard **never asks** about Tor and **never overwrites** an address you set yourself — if `MORPHIT_INSTANCE_TOR_ADDRESS` is already in your environment or an existing config, that value is kept. To actually *serve* the onion, the `tor` Ansible role (`enable_tor`, default-on in `ops/ansible/group_vars/all.yml`) installs Tor and points its `HiddenServiceDir` at those generated keys; set `morphit_tor_key_src` to the `tor-hidden-service/` directory so Tor serves the **same** address the site advertises. A custom **vanity** `.onion` is still a manual step (generate with `scripts/generate-onion.sh` on your own hardware and paste it below) — neither the wizard nor this menu grinds vanity keys for you; pasting a vanity address simply replaces the basic one (and, being a manual value, is never overwritten).
> **I2P `.b32.i2p` is automatic too (cp406).** Every instance also gets a **basic `.b32.i2p` by default** — same privacy-first posture as the onion. The one difference: an I2P destination can't be minted in Node the way a Tor onion can (it bundles an encryption key — ElGamal/X25519 — plus an Ed25519 signing key), so the wizard uses your host's **i2pd** to mint the keyfile. That means **i2pd must be installed** for auto-generation (the `i2pd` Ansible role installs it; on a box that's already reached the I2P network this takes a few seconds). When i2pd is present the wizard mints a destination, derives its address as `base32(SHA-256(keyfile[0:391]))` — the standard I2P hash-of-destination, verified against i2pd itself — writes `MORPHIT_INSTANCE_I2P_B32_ADDRESS` so the **footer pill** lights up, and saves the i2pd keyfile plus a ready-to-append tunnel stanza to an `i2p-tunnel/` directory (`morphit-web.dat` 0600, `tunnel.conf`). The wizard **never overwrites** an address you already set (env var or existing config) or one you typed in the alt-network step — an **operable existing b32 is preserved**. If i2pd isn't installed, the wizard simply skips I2P (add it later from the menu). To actually *serve* it, the `i2pd` Ansible role (`enable_i2pd`, default-on in `ops/ansible/group_vars/all.yml`) installs i2pd, writes the `[morphit-web]` server tunnel (`signaturetype = 7`) to `tunnels.conf`, and installs your wizard keyfile — **only when the server has none**, so an operable keyfile on the box is never clobbered. Set `morphit_i2pd_key_src` to the `i2p-tunnel/` directory so i2pd serves the **same** address the site advertises; otherwise i2pd self-generates a *different* one (the role prints the `head -c 391 … | sha256sum | base32` command to read it, so you can update the env to match). A custom **vanity** `.b32.i2p` remains a manual step (`scripts/generate-i2p.sh`), replacing the basic one.
`morphit-ops alt-address` (also the **"Set up a Tor / Lokinet / I2P address"** main-menu item) is a guided **CRUD** tool for your privacy-network addresses. Pick an address type and it **shows the current value**, then offers **Replace · Delete · Back** (Delete removes the line from `morphit.config.env` and the pill from the footer). I2P now appears as **two separate menu choices** — the always-resolvable **b32** (`DOMAIN.b32.i2p`, generated) and an optional **vanity name** (`DOMAIN.i2p`, which you *register* with an i2p naming service and paste — not generated). For Tor / Lokinet / I2P-b32, "Replace" walks you through *generating* the address; the **I2P vanity name**, **Nostr** (cp311), and **ENS** (`DOMAIN.eth`, cp334) are values you already own/register elsewhere, so they just prompt for the value. An instance may set neither, one, or both i2p addresses. The relevant knobs:
| Variable | Footer field | Notes |
| --- | --- | --- |
| `MORPHIT_INSTANCE_TOR_ADDRESS` | `tor` | v3 `.onion` |
| `MORPHIT_INSTANCE_LOKINET_ADDRESS` | `lokinet` | full `.loki` **or** an ONS name |
| `MORPHIT_INSTANCE_I2P_B32_ADDRESS` | `i2p_b32` | always-resolvable `.b32.i2p` (preferred; the wizard WRITES this key) |
| `MORPHIT_INSTANCE_I2P_NAME_ADDRESS` | `i2p_name` | optional readable `name.i2p` (addressbook-dependent) |
| `MORPHIT_INSTANCE_I2P_ADDRESS` | — | legacy single var; still honored, routed to `i2p_b32`/`i2p_name` by suffix. The wizard reads it (as a fallback) and clears it whenever you set/delete the i2p address, so a value can't linger under two keys. |
| `MORPHIT_INSTANCE_NOSTR_PUBKEY` | `nostr` | Nostr public key — `npub1…` (bech32) or 64-char hex. The wizard rejects a private key (`nsec…`). Also editable via `morphit-ops edit → Branding & SEO`'s alt-networks section. The footer renders this as a "Nostr" pill linking to your instance's Nostr page; it also appears as an alt-network chip on your directory card. |
| `MORPHIT_INSTANCE_ENS_NAME` | `ens` | optional ENS `.eth` name (`DOMAIN.eth`) — a registered Ethereum name you point at this instance (typically via an ENS contenthash → IPFS copy of the site). Paste-only (nothing to generate); the wizard validates the `.eth` shape. Also editable via `morphit-ops edit → Branding & SEO`'s alt-networks section. The footer renders it as an "ENS" pill linking to the bare name (`https://<name>.eth`); not resolved server-side. ENS-aware browsers (Brave, MetaMask, etc.) resolve `<name>.eth` directly — Morphit deliberately does NOT route through a centralized ENS gateway (e.g. eth.limo), so the pill is a no-op in a vanilla browser without ENS support. |
The indexer reads these at startup and includes them in its instance announce; the frontend renders the footer pills from that announce at runtime. So a new/changed address appears after the indexer restarts — the `alt-address` / `edit` wizard **offers to do that for you** (press Enter at the prompt) — **no frontend rebuild needed.**
> **Your own directory card (cp311):** the bold name + alt-network chips on **your own** row of `/instances` come from a *cached* probe snapshot. The indexer never network-probes its own public URL (hairpin-NAT), so it now reads its own branding (`MORPHIT_INSTANCE_NAME`/`_TAGLINE`/`_CONTACT_URL` + these alt-network vars) straight from config and refreshes the cache on each self-probe. Your card therefore updates within one good-status probe cycle (~10 min) after the indexer restarts — not instantly like the footer (which reads the announce live). Before cp311 the own-card name was stuck on your operator-account fallback regardless of `MORPHIT_INSTANCE_NAME`.
**Vanity reality differs per network** (the wizard is explicit about this):
- **Tor** — full prefix vanity via `mkp224o` (`scripts/generate-onion.sh <prefix>`). Time grows exponentially with prefix length.
- **I2P** — prefix vanity via `i2pd-tools`' `vain` (`scripts/generate-i2p.sh <prefix>`), but the `.b32.i2p` is `base32(sha256(destination))`, so only a *short* prefix is feasible (~15 chars quick, 6 ≈ minutes, 7+ hours). `vain` writes a `private.dat` that i2pd reads directly. A readable `name.i2p` is a registrar step (`reg.i2p`/`stats.i2p`), not local key-grinding.
- **Lokinet** — **no prefix vanity.** A `.loki` is the SNApp's ed25519 pubkey and Lokinet generates that keyfile itself (set `keyfile=` in `lokinet.ini` `[network]`, restart, read the address). There is no vanity-key import path and no `lokinet-vanity` tool. A readable `name.loki` is **ONS**: burn OXEN on-chain via the Oxen wallet (110 yr). `scripts/generate-lokinet.sh` prints the setup + ONS steps.
**Key-security model** (unchanged, matches `generate-onion.sh`'s long-standing design): vanity keys are generated on **operator hardware**, never committed (`hidden-services/` is git-ignored), and hand-carried to the box over SSH. Only the **public address** ever enters `morphit.config.env` → footer. For encrypting an alt-network service key at rest under the relay passphrase, see `import-altnet-key` / `export-altnet-key`.
---
## 24. HTTP/2 deployment requirement (Phase F.5 cross-page trade events)
**Bottom line:** terminate TLS with HTTP/2 (or HTTP/3) enabled. The
cross-page trade-event listener won't work reliably under HTTP/1.1.
### Why
Phase F.5 introduced a global SSE listener that opens one chat
stream per recent peer. Under HTTP/1.1, browsers limit
concurrent connections to **6 per origin**. Each open SSE stream
holds one of those slots for its lifetime. Beyond the limit,
new streams queue indefinitely — the user's badge stays at
"Payment pending" forever even after the buyer's funds-sent
message reaches the indexer.
The Phase F.5 audit fix (F-21) caps the listener to **5**
concurrent streams. This leaves 1 connection slot free under
HTTP/1.1 for ad-hoc requests (profile fetches, stream
reconnects, image loads). But if you're running HTTP/2, the
practical limit is 100+ streams over a single TCP connection,
and the cap is a soft optimization rather than a hard
requirement.
### How to verify
From a browser DevTools network tab on your deployment:
1. Load `/orderbook` or `/my/orders` while logged in
2. Inspect any SSE request (`/v1/chat/.../stream`)
3. **Protocol** column should show `h2` or `h3`, not `http/1.1`
From the command line:
```bash
curl -I --http2 https://your-instance.example/orderbook | head -1
# Should print: HTTP/2 200
```
### How to enable on nginx
```nginx
server {
listen 443 ssl http2; # ← http2 keyword
listen [::]:443 ssl http2;
# ... rest of config
}
```
For HTTP/3 (optional, recommended if your nginx supports it):
```nginx
server {
listen 443 quic reuseport;
listen 443 ssl;
http2 on;
http3 on;
add_header Alt-Svc 'h3=":443"; ma=86400';
# ...
}
```
### How to enable on Caddy
Caddy enables HTTP/2 by default for HTTPS sites. Nothing to
configure. Verify with `curl -I --http2`.
### Symptoms of HTTP/1.1 deployment
- /my/orders badges stuck at "Payment pending" indefinitely
even though the chat page shows the funds-sent message
- Browser DevTools shows multiple SSE requests in `pending`
state for several minutes
- Network panel shows >6 simultaneous requests blocking each
other
If the user complains "I never see paid trades update on
/my/orders," check HTTP/2 first.
### Why not raise the cap to 20?
HTTP/2 multiplexing makes the cap mostly cosmetic — under
HTTP/2 you could safely run 20 streams. But the listener
also has a CPU and memory cost (one EventSource per peer
+ decryption work on every message). 5 is a comfortable
default that covers active conversations and keeps the
listener's cost bounded. Operators who want to raise it
can patch `MAX_LISTENER_STREAMS` in
`apps/web/src/lib/trades/tradeEventListener.ts`.
## 25. Going live — staging procedure + chain-switch
For pre-launch staging and the procedure to wipe-and-switch
to mainnet for real, see [`SWITCHING-NETWORKS.md`](SWITCHING-NETWORKS.md).
It walks through staging-on-mainnet (the recommended
pre-launch testing pattern) followed by a destructive
wipe-the-DB-and-switch-to-production procedure for the
launch transition. Roughly 30 minutes for the staging
setup + 15 minutes for the launch switch.
Headline guidance:
- **Staging-on-mainnet is the recommended pre-launch
pattern.** Run a second Morphit instance with a separate
Blurt account, separate Postgres database, separate config
directory. Same chain as production, isolated identity.
This catches Postgres permissions, systemd setup, Caddy
config, federation discovery, and the wizard end-to-end —
everything a sysadmin shakedown actually needs.
- **The chain_id pin is a feature, not a bug.** The indexer
refuses to boot if its config's `MORPHIT_INDEXER_CHAIN_ID`
differs from the value recorded in the `indexer_state`
table. This prevents accidental cross-chain corruption.
To switch chains you MUST drop and recreate the database —
there's no in-place switch.
- **Staging-and-production-on-mainnet is the same chain.**
No chain-id change is needed when "switching" from staging
to production in the recommended pattern; you just drop
the staging DB, create a fresh production DB, and re-run
the wizard with production credentials. The wizard ships
mainnet chain_id by default for both.
- **A community-maintained Blurt testnet exists** at
`https://testnet-rpc.beblurt.com` but Morphit can't talk
to it without code changes (mainnet asset symbol and
address prefix are hardcoded in ~7 places). Tracked in
REVISIT-LIST §D as a deferred capability; not a pre-launch
blocker. See SWITCHING-NETWORKS.md appendix for context.
The full procedure including separate Postgres roles, separate
systemd units, archived configs for rollback safety, and
post-launch smoke testing is in `SWITCHING-NETWORKS.md`.
## 26. Release signing (SHA-256 + GPG)
> **The canonical release is built by CI, not by hand.** When you push a
> signed tag, `.forgejo/workflows/release.yml` builds the tarball, hashes
> it (SHA-256), signs it if a signing secret is set, **publishes the
> Forgejo release, attaches the assets, and writes the on-chain
> `distribution-anchor.env`** — the bytes people download come from that
> job, and the ELI5 release ceremony fetches the anchor it wrote. You
> normally never run `release-sign.sh`. It builds the tarball a different
> way (`git archive`), so its SHA-256 does **not** match the CI-published
> file; keep it only as an offline / air-gapped fallback, and never anchor
> its hash on-chain for a CI release. The rest of this section documents
> that fallback tool.
When you publish a Morphit release tarball, sign it. Users
verifying their downloads against tampering need an
authoritative source-of-truth, and your signing key
fingerprint is that source-of-truth.
### How it works
The script `scripts/release-sign.sh` produces:
- `morphit-v$VERSION.tar.gz` — the source release
- `*.sha256` and `*.sha512` files — independent hash records
- `*.asc` — detached ASCII-armored GPG signature on the tarball
- `CHECKSUMS` — one-file manifest combining the SHAs
- `CHECKSUMS.asc` — detached signature on the CHECKSUMS file
A user can verify with:
```sh
sha256sum -c morphit-v1.2.3.tar.gz.sha256
gpg --verify CHECKSUMS.asc CHECKSUMS
```
### Operator workflow
1. **Tag the release in git.** Use semver: `git tag v1.2.3`.
2. **Run the signing script.**
```sh
./scripts/release-sign.sh
```
The script reads the version from `package.json` (or
accepts `./scripts/release-sign.sh v1.2.3` as override).
3. **Verify the artifact yourself before publishing.**
```sh
cd release
sha256sum -c morphit-v1.2.3.tar.gz.sha256
gpg --verify morphit-v1.2.3.tar.gz.asc \
morphit-v1.2.3.tar.gz
```
4. **Upload to forgejo's releases page.** Drop all six
files (tarball + .sha256 + .sha512 + .asc + CHECKSUMS +
CHECKSUMS.asc) into the Forgejo release UI.
5. **Broadcast a `morphit_release_v1` op on the Blurt chain.**
This puts the SHA-256 on-chain so the frontend can verify
served bundles against it. See
`apps/indexer/src/indexer/handlers/release.ts` for the op
shape.
### Setting up the GPG key
If you don't have a project signing key yet:
```sh
gpg --quick-generate-key "Morphit Releases <releases@your-instance>" rsa4096 sign 2y
gpg --list-secret-keys --keyid-format=long
```
Note the long key ID (16-char hex). Set it as the default
for the release script:
```sh
export MORPHIT_GPG_KEY=<your-key-id>
./scripts/release-sign.sh
```
Then **publish the public key** somewhere durable — your
forgejo profile, a keyserver (`gpg --send-keys <id>` to
keyserver.ubuntu.com), and the Morphit `/security` page.
Users compare against THIS fingerprint to know they're
verifying the right key.
### Why both SHA-256 and GPG?
SHA-256 alone tells the user the file matches a published
hash. GPG additionally tells them WHO published the hash
(provided they trust the key).
If your GPG key is compromised, an attacker can only sign
new bad releases — past releases verified against archived
hashes (e.g. on the Blurt chain via `morphit_release_v1`)
remain valid. Publishing the SHA-256 on-chain at release
time creates a tamper-evident timestamp.
### What gets signed at the chain layer too
The frontend bundle (apps/web/build/) has its own per-file
SHA-256 manifest at `verify.json`, generated post-build by
`scripts/build-verify-json.mjs`. The whole manifest is then
referenced by a `morphit_release_v1` chain op signed by the
project's **private** posting key (WIF). See
`apps/indexer/src/indexer/handlers/release.ts` for the
chain-side handler.
This means there are TWO independent verification paths:
1. **Source release**: GPG signature on the tarball/CHECKSUMS
2. **Frontend bundle**: chain-published manifest hash
Both should match. If they don't, something's off — escalate.
### Decentralized distribution (the source, on many hosts + anchored on-chain)
The source is public on Forgejo, but a single git host is a
single point of failure and censorship. Decentralized
distribution keeps the **same signed code** on independent
hosts and anchors a verifiable pointer to it on the Blurt
chain, so anyone can obtain the code from whatever host is
reachable and PROVE it is the unmodified release.
Three moving parts:
1. **Sign** — `scripts/release-sign.sh <version>` (above). It
also writes `release/distribution-anchor.env` with the
tarball's `source_sha256`, your key's `gpg_fingerprint`, and
the `mirrors` list.
2. **Mirror** — this is AUTOMATIC. Forgejo push-mirrors every
commit and the signed tag to GitHub + Codeberg, so the code
is already on three independent hosts with nothing to do by
hand. Those two hosts are the default anchor mirrors
(override with `MORPHIT_RELEASE_MIRRORS`). Optionally,
`ipfs add` the signed tarball and set `MORPHIT_BUILD_IPFS_CID`
before the payload build to add a content-addressed copy —
off by default, since availability then needs a pinned,
reachable node or a pinning service.
3. **Anchor** — broadcast `morphit_release_v1` with a
`distribution` block carrying `source_sha256`,
`gpg_fingerprint`, the `mirrors` list, and optionally
`ipfs_cid`. The block is public + verification-only — no
secret is ever placed in it (same invariant as the treasury
block). Shape: `packages/release-schema/src/release.ts`
(`ReleaseDistributionBlock`).
The ELI5 ceremony (`scripts/eli5-release.sh <version>`) now
prints these as Blocks 46, filled in and in order, so the
anchor values flow from the sign step into the payload build
without hand-copying.
Anyone can then verify what they got — **not** against any
Morphit server, so a compromised host can't fake a match. Two
paths, depending on how they obtained the code:
```sh
git verify-tag v1.2.3 # a clone, from any mirror
node scripts/verify-download.mjs morphit-v1.2.3.tar.gz # the release tarball
```
See `docs/VERIFY-YOUR-DOWNLOAD.md` for the full user-facing
guide. Both trace back to the one key fingerprint you
published — which is now also anchored on-chain, so a hostile
mirror can't swap in its own key.
#### IPFS pin + a stable "always latest" IPNS name (optional, CI-automated)
The release workflow computes a content-addressed **IPFS CID** for
each release directory and repoints a **stable IPNS name** at it, so
`ipns://<name>` always resolves to the newest release. Both are
additive to the git mirrors + the on-chain SHA-256, and neither can
ever fail a release (the steps are non-fatal). The IPFS CID needs no
secret or account; the IPNS pointer is published only when you've set
the `MORPHIT_IPNS_KEY` secret.
- **IPFS CID (self-hosted — no pinning service)** — `release.yml`
installs the pinned Kubo (v0.42.0, checksum-verified) and computes the
DETERMINISTIC CID of a small **release directory** — the signed tarball
(versioned + a stable `morphit-latest.tar.gz`), its `.sha256`/`.asc`, the
release notes, and a deterministic `metadata.json` — with `ipfs add
--only-hash` (offline; no upload, no account, no secret). That DIRECTORY
CID is anchored on-chain as `ipfs_cid`. The bytes are HOSTED by our own
nodes: your release box seeds it once per release (`morphit-ops harden` →
"Seed this release to IPFS", or `ops/ipfs/morphit-ipfs-seed.sh <tag>`), and
every Morphit instance's Kubo pins it from the network. Because the CID is
deterministic, the seed box's `ipfs add` reproduces exactly the CID CI
anchored (the seed asserts equality). Before broadcasting,
`scripts/verify-cid-public.sh <cid> <version>` confirms the CID resolves on
a public gateway — a release never anchors a CID the world can't fetch.
- **IPNS "always latest" (DHT-native — no DNS, no third party)** — run
`npm i --no-save w3name ipns @libp2p/peer-id && node
scripts/ipns-keygen.mjs` **once** on your release laptop. It prints a
PUBLIC `k51…` name and a SECRET base64 key. Store the key as the
`MORPHIT_IPNS_KEY` Actions secret (never commit it — same trust model as
the @morphit WIF) and paste the `k51…` name into
`apps/web/src/lib/ipns.ts` (`MORPHIT_IPNS_NAME`). From then on every
tagged release **signs** a fresh IPNS record *locally*
(`scripts/ipns-sign.mjs`; the key is used only to sign and stays a CI
secret), points the name at the new CID, and anchors the **signed
record** on-chain as `distribution.ipns_record` (alongside `ipns_name`).
Every Morphit instance then **rebroadcasts** that record to the public
DHT on a ~4h timer — installed by the same `morphit-ops harden` → "Set up
IPFS release hosting" step, so there is NO extra operator action, and the
instance NEVER holds the key (it only relays what @morphit signed). So
`ipns://<name>` resolves over the public DHT for as long as one instance
is alive — no DNS, no gateway, no third party — and no instance can
repoint it. Records live ~1 year and are re-signed every release, so the
name never goes stale. (There is **no DNSLink**: the native `ipns://`
name IS the pointer. On the download page this is the "IPNS (always
latest)" card — which needs an IPFS-capable browser like Brave — beside
an "IPFS (always latest)" gateway card that works in any browser. w3name
is gone: it stored records off the DHT, so public gateways never resolved
them; it is now used only to parse the existing key.)
The `ipfs_cid` is the immutable per-release verification anchor;
`ipns_name` + `ipns_record` are the mutable discovery pointer (the
signed `ipns_record` is what every instance rebroadcasts to the DHT).
A copy fetched via any of them is still only trusted once it passes
the `source_sha256` + GPG checks.
## 27. Fees and rewards reference
The complete fee schedule, reward triggers, and operator
economics are in [`FEES-AND-REWARDS.md`](FEES-AND-REWARDS.md).
That document is the **single source of truth** with line-
number references back to the source code that defines each
figure. If you're answering "how much does X cost" or "what
does the welcome bonus actually pay out", quote that doc, not
chat history or older copies of this runbook.
Headline numbers (for context — full breakdown in the doc):
- **Listing fee:** targets ~12.5¢ USD-equivalent in BLURT (~25¢ in
BTC/XMR) — the canonical `LISTING_FEE_USD` in
`@morphit/asset-registry`. Env base `MORPHIT_INDEXER_FEE_BASE_BLURT`
(default 125 BLURT, ≈ the target at BLURT ≈ $0.001).
- **Stranger fee:** 5 BLURT for cold messages, escalates with abuse.
- **Featured-slot bid:** 50 BLURT/hour, ≥6 hours minimum.
- **Account-creation cost:** ~100 BLURT per signup (operator's
biggest expense — paid by the relay account to the chain;
user signs up free).
- **Welcome bonus:** 10 BLURT liquid + 10 BLURT Power on first
completed trade.
- **Loyalty:** up to 1,260 BP delegated as cumulative fees
cross 100/500/2,000/10,000 BLURT thresholds.
- **Operator earnings:** 90% of BLURT-paid listing fees,
delivered **directly at payment time** via the fee split —
90% to your fees account, 10% to the canonical treasury, in
the user's own fee transaction (FEES-AND-REWARDS.md "How
listing fees split" section). The canonical treasury keeps
100% of BTC/XMR-paid fees; the BLURT/treasury asymmetry is
structural, not greed.
The `fee-reward-copy-consistency-smoke` runs in the smoke
suite to detect drift between the source code and the
user-facing copy. If you change a fee or reward in the code,
the smoke will fail until you update FEES-AND-REWARDS.md and
the relevant FAQ entries to match.
---
## 28. Operator-earnings monitoring
Your 90% is paid **directly, at payment time** — the user's fee
transaction sends 90% straight to your fees account
(`MORPHIT_INDEXER_FEE_RECIPIENT`) and 10% to the canonical
treasury, in the same transaction. There is no relay payout to
watch and nothing to reconcile: if an order verified, your 90%
already landed. So "monitoring earnings" is two independent things:
1. **The money** — the BLURT itself, which arrives in your fees
account on-chain. Check its balance on a chain explorer
(https://blocks.blurtwallet.com or similar) or in your wallet.
2. **The dashboard attribution** — a running tally the indexer
keeps for display, driven by your `operator_tag`. This is
audit/analytics only; it does NOT move money.
### Verifying earnings are flowing
```
curl -s http://localhost:8081/v1/operators/yourtag | jq
```
Look for:
- `cumulative_blurt_earned` — total BLURT the dashboard has
credited you (your 90% shares summed), > 0 once BLURT-paid
orders have been attributed to your tag.
- `total_orders_attributed` — count of orders attributed to you.
And separately, confirm the actual BLURT balance of your fees
account on-chain — that is the ground truth for the money.
### If the money didn't arrive
The 90% is a transfer inside the user's fee transaction, so if it
didn't arrive, the fee transaction itself must have failed — which
means the order never reached `fee_status='verified'`:
1. **Does your fees account exist on chain?** The 90% transfer is
addressed to `MORPHIT_INDEXER_FEE_RECIPIENT`. If that account
doesn't exist on Blurt, the chain rejects the whole fee
transaction, so the order can't verify. Create the account
before your first fee, or fix a typo'd name (`morphit-ops
edit` → **Fees account**), then restart the indexer.
2. **Check the boot warning.** If the indexer logged
`fee_recipient_invalid` at boot, your configured name was
rejected and fees fell back to the canonical `@morphit-fees`.
The frontend pays exactly what your indexer verifies against,
so they never silently disagree — but a rejected name means
you're donating your fees to canonical until you fix it.
3. **Inspect a recent order's fee transaction** on a chain
explorer: it should contain a `transfer` of ~90% of the fee to
your account and ~10% to `@morphit-fees` (or a single 100%
transfer to `@morphit-fees` if you run the canonical account).
### If the dashboard attribution is missing (but the money arrived)
The money and the dashboard are independent: the split pays your
fees account regardless of the tag; the `operator_tag` only drives
the dashboard credit. If your balance grows but
`/v1/operators/yourtag` shows nothing:
1. **Did the order op include your tag?** Inspect a recent order
op's payload on a chain explorer — its `operator_tag` must
match your registered tag exactly. If missing, your frontend
isn't sending it — check `MORPHIT_INSTANCE_OPERATOR_TAG` in
`ops/env/indexer.env` and confirm the indexer was restarted.
2. **Is your tag in the operators table?**
```
docker-compose exec postgres psql -U morphit_indexer -c \
"SELECT account, tag, is_active FROM operators WHERE tag = 'yourtag';"
```
0 rows → the on-chain registration didn't land or didn't index.
3. **Was the order's BLURT fee verified?** Attribution only fires
for `fee_status='verified'` BLURT orders (BTC/XMR-paid orders
don't attribute — 100% goes to the canonical treasury).
```
docker-compose exec postgres psql -U morphit_indexer -c \
"SELECT permlink, fee_method, fee_status FROM orders \
WHERE account = 'orderposter' ORDER BY created_at DESC LIMIT 5;"
```
4. **Is the attribution event recorded?**
```
docker-compose exec postgres psql -U morphit_indexer -c \
"SELECT operator_tag, fee_blurt, operator_share_blurt \
FROM operator_attribution_events \
WHERE operator_tag = 'yourtag' \
ORDER BY observed_at DESC LIMIT 5;"
```
### Edge cases to know about
**Operator deactivation.** If `operators.is_active = FALSE` for
your row (a future moderation feature), new attributions skip you
silently — the dashboard stops crediting you, though any fees your
account already received (they were paid directly) stay yours.
There is no pending balance to strand.
**Self-dealing / mis-tagging.** If a hostile user lists orders
through your instance carrying a *different* operator's tag, that
other operator gets the dashboard credit — but the **money** still
splits to *your* instance's fees account (the split follows your
configured recipient, not the tag) plus the canonical 10%. So
mis-tagging can only misattribute the dashboard analytics, never
misroute the fees. The public `operator_attribution_events.operator_tag`
audit trail shows which tag was credited per order.
## 29. Running a second instance — DO NOT share relay accounts
A natural question from operators considering running a
second instance (different domain, different VPS, different
host): "Can I just reuse my existing `@my-relay` and
`@my-fees` accounts, point both deployments at them, and
run two frontends in parallel?"
**Short answer: NO.** The chain itself doesn't reject the
attempt, but the two relays will conflict in ways that
silently corrupt user signups, cause double-spends on the
welcome-bonus drainer, and halve your abuse defenses.
This applies whether the "second instance" is a competing
brand or just a backup deployment — running two relays
signing with the same active key concurrently is broken.
### Why it breaks
**Drainer-queue double-spend.** When a user X signs up via
Instance A and later completes their first trade, Instance
A's indexer atomically flips X's `first_trade_complete_at`
in A's Postgres and queues two `relay_pending_transfers`
rows for the welcome bonus (10 BLURT liquid + 10 BLURT
vesting). Instance B's indexer, watching the same chain,
sees the same feedback op and atomically flips X's
`first_trade_complete_at` in B's Postgres (different
database, different guard, no cross-instance coordination)
and queues another two transfers in B's queue. Now
**both** drainers, both signing as `@my-relay`, broadcast
their transfers. The chain accepts both — they're
distinct transactions. User X walks away with 20 BLURT
liquid + 20 BLURT vesting instead of 10 + 10. Multiplied
across thousands of first-time traders, this drains the
relay account twice as fast as it should.
**Same problem on every other queued payout.** The
welcome-bonus case above is just one example — the same
double-broadcast pattern applies to **every** transfer
the drainer queues: loyalty milestone BP delegations,
operator-payout splits (see §28), waiver bonuses, the
1-BLURT auto-refill dust. Every single one of these
will fire from both relays independently because each
indexer's queue is local. The chain is happy to accept
duplicate-payload transfers as long as the trx hashes
differ (different block refs make them differ
trivially), so there's no chain-level protection.
**Concurrent transaction signing race on retries.** Blurt
uses TaPoS (transactions-as-proof-of-stake): every signed
op carries a `ref_block_num` / `ref_block_prefix` plus an
expiration window (default ~60 s). Independent ops from
each relay land fine. But on **retries** of the same op
(transient RPC failure, exponential backoff), if both
relays happen to sign the same operation with the same
ref-block, the chain rejects the second as a duplicate
trx hash — and the relay that retried thinks the signup
or transfer failed even though it already landed. The
user sees "signup failed" while the account exists on
chain. Hard to debug, easy to repeat, no telemetry to
catch it.
**Halved abuse defenses.** The daily signup ceiling
(`max_signups_per_day`), per-IP invite-token spacing,
and Altcha PoW challenge counters all live in each
instance's own Postgres. Two instances sharing a relay
account means an attacker gets two independent budget
pools — you've doubled your daily Sybil cap without
realizing it.
**Fee collection (the read-only side) is fine.**
`@my-fees` only RECEIVES transfers from users posting
orders; it never signs anything from the relay. Two
indexers reading the same fee account's incoming transfer
stream is harmless — they're both observing the chain,
not writing to it.
### What to do instead
If you want to run a second instance:
1. **Generate fresh accounts for the second relay.** Use
`@my-relay-2` (active key separate from the first), and
either share `@my-fees` (if you want one treasury for
both deployments) or use `@my-fees-2`.
2. Each instance gets its own Postgres database, its own
federation registration (`morphit_operator_register_v1`
with a distinct operator tag), its own setup wizard
run.
3. Both indexers read the same chain, so the orderbook is
federated automatically — that's the design.
If you want a hot-standby DR setup (one relay live, one
ready to take over if the first dies), the standard
pattern is **shared Postgres + active/passive relay
processes** with a leader-election lock, NOT two
independently signing relays. That's an unbuilt feature
on the backlog — for now, plan for restore-from-backup
rather than live failover.
## 30. Postgres provisioning — the password sentinel and the init script
The repo ships `ops/postgres/init.sql` to provision the
`morphit_indexer` Postgres role and database with the
correct privileges. It is **opinionated about passwords**:
the script reads the password from the
`MORPHIT_INDEXER_DB_PASSWORD` environment variable and
refuses to run if the variable is unset, empty, or set to
one of the well-known placeholder strings that have
appeared in this repo's example `.env` files.
The reject list (kept in sync between the SQL script, the
indexer's Zod config, the relay's Zod config, and the
`db-password-placeholder-smoke`):
- `CHANGEME`
- `CHANGE_ME`
- `CHANGE_ME_BEFORE_PRODUCTION`
- `__SET_BEFORE_DEPLOY__`
- `password`
- `postgres`
This catches the most common pre-launch mistake: copying
`ops/env/indexer.env.example` to `ops/env/indexer.env`
without editing the password line. Boot of either the
indexer or the relay will refuse to proceed if any of
these strings appear in the password component of the
DATABASE_URL.
### Provisioning procedure
```
# 1. Pick a strong password — at least 32 chars, mixed
# classes, generated by `openssl rand -base64 32` or
# similar. Record it in your password manager.
export MORPHIT_INDEXER_DB_PASSWORD='<generated>'
# 2. Run the init script as the postgres superuser. The -E
# flag preserves your env var across the sudo boundary.
sudo -E -u postgres psql -f ops/postgres/init.sql
# 3. Construct the DATABASE_URL with that password and
# write it into ops/env/indexer.env and ops/env/relay.env:
echo "MORPHIT_INDEXER_DATABASE_URL=postgresql://morphit_indexer:${MORPHIT_INDEXER_DB_PASSWORD}@localhost:5432/morphit_indexer" \
>> ops/env/indexer.env
# 4. Unset the env var so the password isn't sitting in
# your shell history or process environment.
unset MORPHIT_INDEXER_DB_PASSWORD
# 5. chmod 0600 the env files.
chmod 0600 ops/env/indexer.env ops/env/relay.env
```
### What the script does
1. **Reads `MORPHIT_INDEXER_DB_PASSWORD` from the env**
via `\getenv`. Defaults the psql variable to empty
string so an unset env var falls through to the same
reject branch as an empty value (clean exit code, no
`\quit`-without-status footgun).
2. **Rejects empty / placeholder values via a `DO` block**
that calls `RAISE EXCEPTION` with a human-readable
message. Exit code is 3 (psql's standard "SQL error")
so automated runners can detect failure.
3. **Creates the `morphit_indexer` role** with the
provided password.
4. **Creates the `morphit_indexer` database** owned by
that role, encoded UTF-8, locale `en_US.UTF-8`.
5. **Locks down the role**: `NOSUPERUSER NOCREATEDB
NOCREATEROLE`. The role owns its own database so it
can run migrations, but cannot create other databases
or roles.
6. **Resets the session GUC** so the password doesn't
linger in the connection.
### Runtime guardrails
The same reject list is enforced at boot time:
- `apps/indexer/src/config/index.ts` Zod schema refuses
to accept a `MORPHIT_INDEXER_DATABASE_URL` whose
password component is one of the placeholders.
- `apps/relay/src/config/index.ts` enforces the same
refinement on `MORPHIT_RELAY_DATABASE_URL`.
If you forget to edit the example file, the indexer or
relay will fail at startup with a clear error message
instead of running on a guessable password. CI also
catches re-introduction of any placeholder string in
tracked source via the `db-password-placeholder-smoke`.
### Rotating the password later
The same procedure works for rotation. The role already
exists, so use `ALTER ROLE` instead of `CREATE ROLE`:
```
sudo -u postgres psql -c "ALTER ROLE morphit_indexer PASSWORD '<new-pw>';"
```
Then update the DATABASE_URL in both env files and
restart indexer + relay. The Zod schemas re-validate at
boot — there's no in-memory cached connection string.
---
## 31. Daily DB backup automation
Promoted from a copy-paste recipe in `docs/RUN-A-MORPHIT-NODE.md` to a first-class wizard step in Audit Part 32 (2026-05-04). The shipped files:
```
ops/backup/morphit-backup.sh # the script (shipped, generic)
ops/backup/backup.env.example # config template
ops/systemd/morphit-backup.service # systemd oneshot service
ops/systemd/morphit-backup.timer # daily at 04:00 local
```
### Verifying backups ran (status dashboard)
`morphit-ops status` (main-menu item #10, "Status dashboard") ends with a **Backups** section that lists the backup directory and the **3 most recent** backup files — each with its age and size — so you can confirm at a glance that the timer is actually producing backups. It resolves the directory from `MORPHIT_BACKUP_DIR`, else `BACKUP_DIR` in `/etc/morphit/backup.env`, else the default `/home/morphit/backups`. The section prints the on-disk path so you can copy a file off the host (e.g. `scp`) to download it or hand it to a developer. It is read-only — it never creates, deletes, or rotates backups; rotation stays the timer's job. (`--json` includes the same data under a `backups` key for scripting.)
### Wizard flow
`morphit-ops init` step 17 asks: "Enable daily DB backup automation?" — default **Yes**. If yes, also asks for:
- Backup directory (default `/home/morphit/backups`)
- Retention days (default 30)
The wizard writes `ops/backup/backup.env` to the repo with operator-specific values (the script and systemd units are static and stay generic). The post-install summary prints these install commands:
```
sudo install -m 640 -o root -g morphit ops/backup/backup.env /etc/morphit/backup.env
sudo install -d -m 755 /usr/local/lib/morphit
sudo install -m 755 ops/backup/morphit-backup.sh /usr/local/lib/morphit/
sudo install -m 644 ops/systemd/morphit-backup.service /etc/systemd/system/
sudo install -m 644 ops/systemd/morphit-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-backup.timer
```
**Two preconditions that will otherwise bite you.** The shipped unit runs as `User=morphit`, not root:
- **The env file must be readable by that user.** That is why the install above is `-m 640 -o root -g morphit` and not `-m 600 -o root -g root` — with root-only permissions the script's `[ -r "$BACKUP_ENV" ]` guard fails and the very first `systemctl start` dies with `cannot read /etc/morphit/backup.env`.
- **On a containerized Postgres, that user needs docker.** The dump runs through `docker exec`, so the backup user must be in the `docker` group (`sudo usermod -aG docker morphit`). Docker-group membership is root-equivalent — grant it only on nodes that actually run a containerized DB.
Also: if your backup directory already exists and is owned by **root** — e.g. an earlier root-run backup script created it — hand it to the service user (`sudo chown -R morphit:morphit /home/morphit/backups`), or the dump cannot be written.
**Always prove the first dump.** A backup you have never seen succeed is not a backup; run the `systemctl start` + `journalctl` check below and confirm a real byte count and a real file on disk.
### Clearing a self-trade flag (restoring an account)
Signals A and B are heuristics, and honest activity can trip them — two accounts created from the same machine and reviewing each other looks, to a detector, exactly like self-dealing. A flagged account loses its reputation card, and its reviews are shown subdued behind a "Not counted toward rating — reviewers flagged as related" pill.
Since v1.8.9, `morphit-ops` → **Moderation** offers **"Clear a flag (restore an account)"** alongside block and unblock. Pick the signal, name the two accounts (either order), and confirm. The account is restored immediately — the reputation card returns and the reviews stop being subdued — because every reputation and review query still reads the flag tables and the pair is simply no longer in them.
The clearance is also recorded, and that half matters just as much: the detectors re-run continuously and would re-raise the identical flag on their next pass, so **deleting a flag row on its own appears to work and then silently undoes itself.** A recorded clearance is what makes the restore permanent.
**The two signals are cleared with different lifetimes, on purpose.**
- **Related accounts (Signal A) — permanent.** It keys on account-*creation* facts: same creator, first activity minutes apart. That evidence is immutable, so a clearance that expired would re-flag the same pair forever on the same unchangeable evidence, and you would be clearing it on a treadmill.
- **Mutual reviews (Signal B) — forgiven, then watched.** It is behavioural, so clearing records a *watermark*: the pair's mutual-review count at that moment. Everything up to the mark is forgiven; the flag re-fires only once they accumulate another full signal's worth beyond it. A pair cleared by mistake is therefore still caught by *new* behaviour, while an honest pair is never re-flagged on the same forgiven history. Note that a fixed time window would do the opposite — it re-flags on the old evidence the moment it expires.
Because a pair that trips one signal usually trips both, the menu offers **"Both signals for this pair"** as the leading option; the clearances list shows each one's lifetime (`permanent`, or `watched from N mutual reviews`).
Clearances are instance-local, like blocking: nothing is broadcast, and no other instance's view of those accounts changes. The same menu lists the clearances in force so you can review or undo them; undoing simply makes the pair eligible for flagging again — it does not re-raise a flag by hand.
**Then let `morphit-ops health` watch it for you.** Since v1.8.9 the health report carries a `Backups` line beside `Services` and `Canary`:
```
Backups ✓ fresh
Newest dump: morphit-20260722-042219.sql.gz (398K, 13h ago)
a recent dump is on disk
```
The states are worth knowing, because they are not all equally bad:
- `✓ fresh` — a dump landed within the last 36 hours (wide enough to absorb the timer's randomised delay).
- `⚠ stale` — the newest dump is older than that, so at least one nightly run was missed.
- `✗ failing` — one of three things: the unit is in systemd's `failed` state, **the timer fired more recently than the newest dump** (a run executed and produced nothing), or **the newest dump is too small to be a dump at all**. The second case is the shape of the v1.8.4v1.8.7 dash bug, where the timer ran faithfully every night and silently wrote nothing. The third is its successor, fixed in v1.8.10 and described below — a dump that is perfectly *recent* and completely useless.
- `✗ missing` — configured, but no dump has ever been written.
- `○ not-configured` — no `/etc/morphit/backup.env`. Running your own backup instead is a legitimate choice, so this is reported neutrally rather than as a fault.
- `⚠ unreadable` — the CLI could not read the env file or the backup directory. Note this is deliberately **not** reported as "missing": `backup.env` is `640 root:morphit` and the dump directory is `700 morphit:morphit`, so another user genuinely cannot look. Run as the morphit user (or with sudo) to see freshness.
A manual `systemctl start` does not update the timer's last-trigger, so a hand-run dump never reads as a failure.
### A failed dump is no longer kept (v1.8.10)
Through v1.8.9 the backup script could report success on a dump that had failed. POSIX `sh` has no `pipefail`, and Debian/Ubuntu build `dash` without it, so `pg_dump | gzip` reported **gzip's** exit status — zero — even when `pg_dump` had died. The emptiness guard could not catch it either, because gzip of a failed dump is still a valid ~20-byte file. A refused database connection therefore wrote 20 bytes, renamed it to a real backup filename, printed `wrote … (20 bytes)` and exited 0. The freshness check above then reported it as `✓ fresh`, because it genuinely was the newest file.
From v1.8.10 the script captures `pg_dump`'s own exit status through a file the pipeline cannot swallow, and compares the result against what the same pipeline produces for an empty dump. A failed or empty dump is deleted and the run exits 4 with the real reason. Separately, `morphit-ops health` now reports any newest dump under 1 KiB as `✗ failing` rather than `fresh` — the indexer schema alone compresses to tens of kilobytes, so a legitimate dump is never close to that floor.
**What this means if you are upgrading.** If your database has an intermittent problem, you may now see backup failures where you previously saw silent success. That is the fix working, not a new fault — those runs were already failing, you were just not being told. Two things are worth doing once:
```bash
# 1. Re-install the script so the box runs the fixed version.
sudo install -m 755 ops/backup/morphit-backup.sh /usr/local/lib/morphit/
# 2. Look for fragments left by the old behaviour, and delete any you find.
# A real dump is tens of KB at minimum; anything in the tens of BYTES is
# a failed run that was banked as a restore point.
ls -lS /home/morphit/backups | tail -20
```
Then run `morphit-ops health` and confirm the **Backups** line reports a plausible size alongside the age.
The script is installed to `/usr/local/lib/morphit/morphit-backup.sh` (NOT executed in-place from the repo). This decouples the systemd unit from the operator's repo location: the unit's `ExecStart` is hardcoded to the stable system path, so it works regardless of whether the repo is at `/home/morphit/morphit`, `/opt/morphit`, or anywhere else. When `git pull` brings in script changes, the operator re-runs just the `sudo install -m 755 ops/backup/morphit-backup.sh ...` command.
### Containerized Postgres (Docker-aware backups)
If your Postgres runs inside a **Docker container** (a BunkerWeb or `docker-compose` stack — there's no `pg_dump` on the host, and the DB isn't on a host socket), the backup dumps **through the container**:
```
docker exec "$DB_CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" | gzip > backup.sql.gz
```
driven by one field in `/etc/morphit/backup.env`:
```
DB_CONTAINER=bunkerweb-db-1 # set → docker-exec path; empty → host pg_dump
```
**You do not set this by hand.** `morphit-ops init` and **every** `morphit-ops upgrade` auto-detect a containerized Postgres and fill `DB_CONTAINER` for you — detection is name-agnostic (it finds a running Postgres-family container that actually hosts the morphit database via the same trust/peer path the backup will use, never a hard-coded name). `DB_NAME` and `DB_USER` are likewise **derived from your `MORPHIT_INDEXER_DATABASE_URL`** at setup (init and harden), so a non-standard box — e.g. a BunkerWeb DB on `morphit_user`/`morphit_db` — is backed up correctly without any hand-editing; probing the container under the real identity also makes detection a provable match rather than a best-guess. On the container path, `pg_dump` runs *inside* the container against its own local socket, so `DB_HOST`/`DB_PORT` are ignored and no password is needed (container-local trust/peer auth).
If you set up backups **before** this became automatic and your DB is containerized, the daily backup was quietly dumping the host (capturing nothing). The next `morphit-ops upgrade` detects that drift and prints the exact one-line fix, e.g.:
```
sudo sed -i 's/^DB_CONTAINER=.*/DB_CONTAINER=bunkerweb-db-1/' /etc/morphit/backup.env
sudo systemctl start morphit-backup.service # test it now
journalctl -u morphit-backup.service -e
```
The backup user must be able to run `docker` (in the `docker` group); if `DB_CONTAINER` is set but `docker` isn't reachable, the script loud-fails rather than silently falling back to a host dump of the wrong (or empty) database. Restoring a containerized DB likewise goes through the container — see the restore drill below.
### Why systemd timer (not cron)
- Failures land in `journalctl -u morphit-backup.service` alongside Morphit's other service logs.
- `Persistent=true` means a missed run (laptop suspended at 04:00, server rebooting) fires when the timer is next active — daily-ish becomes daily.
- `RandomizedDelaySec=30m` smears wakeup across operators in a fleet, so a coordinated 04:00:00 hammer doesn't hit Blurt RPCs simultaneously (the indexer might be mid-poll when the dump starts).
- `OnFailure=` directives can email or page on a failed backup (operator wires this to their existing alert chain).
### Why backup.env lives in /etc, not the repo
The systemd service runs as the `morphit` system user with hardening directives (`ProtectSystem=strict`, `ProtectHome=read-only`, `NoNewPrivileges=true`). For a service like that, the operator config file at `/etc/morphit/backup.env` is the standard pattern:
- Root-owned 600, so a compromised application user can't tamper with the backup target dir.
- Survives a `git pull` (the wizard writes to the repo, but the deployed copy lives outside).
- Operator can change retention days or backup dir without touching the repo or restarting anything — the script reads the env file at the start of each run.
The wizard does not write to `/etc` directly because it runs as the operator user (no sudo). It writes to the repo path, prints the install commands above, and the operator runs those once.
### Verifying
```
systemctl list-timers morphit-backup.timer
journalctl -u morphit-backup.service --since '1 day ago'
ls -lh /home/morphit/backups
```
If the most recent backup is older than 26 hours, something's wrong — start with `journalctl -u morphit-backup.service -e` to see the last failure.
### Quarterly restore drill
A backup that's never been tested isn't a backup. Once a quarter, restore the most recent backup to a throwaway database and diff the orderbook count against production:
```
createdb morphit_indexer_test
gunzip < /home/morphit/backups/morphit-YYYYMMDD-HHMMSS.sql.gz | psql morphit_indexer_test
psql morphit_indexer_test -c "SELECT COUNT(*) FROM orders WHERE expired = false"
psql morphit_indexer -c "SELECT COUNT(*) FROM orders WHERE expired = false"
dropdb morphit_indexer_test
```
The two counts should be within ±1 (some orders may have expired between the snapshot and "now"). If they differ by more than that, your backups aren't capturing complete state — investigate before trusting the backups for real recovery.
If your Postgres is containerized (`DB_CONTAINER` set), run the same drill **through the container** — the dump was taken that way, so restore it that way:
```
docker exec "$DB_CONTAINER" createdb -U "$DB_USER" morphit_indexer_test
gunzip < /home/morphit/backups/morphit-YYYYMMDD-HHMMSS.sql.gz | docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" morphit_indexer_test
docker exec "$DB_CONTAINER" psql -U "$DB_USER" morphit_indexer_test -c "SELECT COUNT(*) FROM orders WHERE expired = false"
docker exec "$DB_CONTAINER" dropdb -U "$DB_USER" morphit_indexer_test
```
(Substitute your real `DB_NAME`/`DB_USER` from `/etc/morphit/backup.env`.)
### Off-server replication
The local timer keeps a 30-day rolling backup. If the server burns down, you lose those too. See `docs/RUN-A-MORPHIT-NODE.md §10` for rsync / rclone / S3 recipes. The simplest add-on cron runs 30 minutes after the dump, giving the .partial file time to land:
```
30 4 * * * rsync -az --delete /home/morphit/backups/ user@backup-host:/path/to/morphit/
```
### What this does NOT cover
- The posting key (`apps/relay/keystore.{wif,json}`) — back up separately, see §9 "Paper-key backup protocol" for the secure off-server pattern.
- Encryption at rest of the backup directory itself — operator's choice (filesystem-level LUKS on the backup partition is the simplest pattern).
- Verifying that the off-server copy is intact — rsync's `--checksum` or rclone's `verify` should be added to the off-server cron if losing both copies would be catastrophic.
---
## 32. BunkerWeb — recommended WAF / reverse-proxy hardening
[BunkerWeb](https://www.bunkerweb.io) is an open-source AGPLv3-licensed reverse proxy with built-in Web Application Firewall (WAF) features. Same license as Morphit; no licensing concern. **Recommended for any public-facing Morphit instance** — the morphit repo ships a canonical, tested-shape BunkerWeb deployment at `ops/bunkerweb/` (paralleling `ops/nginx/`, `ops/systemd/`, etc.). Copy + edit + `docker compose up -d` and you have a WAF-fronted instance with OWASP CRS at paranoia 3, anti-`Referer: none` on the invite endpoint, real-IP forwarding wired correctly to the relay's trusted-proxy chain, and a fixed Docker network CIDR (`172.20.0.0/16`) you can hard-code into `MORPHIT_RELAY_TRUSTED_PROXY_IPS` without re-inspecting after rebuilds.
The Ansible playbook's `bunkerweb` role deploys this directory verbatim. Operators not using Ansible follow the Quick Start in `ops/bunkerweb/README.md`.
**Canonical topology (what `ops/bunkerweb/` ships):** `client ──TLS──> bunkerweb ──> frontend nginx ──> host relay (8080) / indexer (8081)`. BunkerWeb is the only public entry — it terminates TLS, runs the WAF + rate limits, sets the real client IP, then proxies EVERY path (a single `REVERSE_PROXY_HOST=http://frontend:80`) to a lightweight `frontend` nginx container. That container serves the built SvelteKit site for page routes AND reverse-proxies the API paths (`/v1/`, `/relay/`, `/rss/`, and the SSE `.../stream` paths) to the relay + indexer on the host — its routing mirrors `ops/nginx/web.conf` minus the TLS + security headers BunkerWeb owns (see `ops/bunkerweb/frontend/nginx.conf`). Doing all the static-serving + SPA fallback + per-path proxy + SSE in one nginx is far easier to get right than expressing it in BunkerWeb env vars. The relay + indexer therefore bind on an address the Docker bridge can reach (NOT loopback-only — a `127.0.0.1` bind is unreachable from the `frontend` container and every proxied call 502s); UFW's default-deny keeps the public out and the `bunkerweb` role adds an allow for the `172.20.0.0/16` bridge CIDR only. The illustrative BunkerWeb env-var snippets further down show BunkerWeb's setting shapes; the authoritative morphit config is `ops/bunkerweb/` + its `README.md`.
> **Concurrent-connection caps for the chat SSE paths (`/v1/chat/:a/:b/stream`, `/v1/chat-activity`).** The indexer deliberately does NOT apply its per-minute REST rate limiter to these two long-lived SSE endpoints (a persistent stream shouldn't share a per-request budget) — it defers the limit to this proxy layer, on purpose. The relevant DoS surface here is not request *rate* but concurrent-connection *count*: one client (or a botnet) holding thousands of open streams. So the proxy in front of the indexer must enforce a **per-IP concurrent-connection cap** on those paths — BunkerWeb's connection-rate + slow-loris guards cover the common case; if you want an explicit ceiling, the `frontend` nginx (`ops/bunkerweb/frontend/nginx.conf`) is the natural place for an nginx `limit_conn` zone keyed on the real client IP scoped to the `.../stream` and `/v1/chat-activity` locations (a generous cap — e.g. a few dozen per IP — so legitimate multi-tab / multi-device users are unaffected while a single source can't exhaust connection slots). This does not touch fast-path latency: the stream itself is server-push, and the cap only bounds how many streams one source may hold open at once. If you run WITHOUT BunkerWeb, this cap is your responsibility to add at whatever proxy fronts the indexer.
(menu: "Secure the server" → "Web firewall (BunkerWeb): install / status") inspects whether the `bunkerweb` and `bunkerweb-scheduler` containers are running and healthy. If they already are — or if you pass `--json`, or run it non-interactively (no TTY) — it is **read-only**: it reports status and prints the bring-up commands, nothing more. If BunkerWeb **isn't up yet and you're at an interactive terminal**, it offers a **guided, ELI5 installer** that confirms before each step: it copies the shipped `ops/bunkerweb/` config into `/etc/bunkerweb` (never clobbering an existing config), prompts for your domain (`SERVER_NAME`) and validates it, guards against the missing-TLS-certificate crash-loop (it stops and points you at `morphit-ops ssl` first if the cert isn't there yet), then runs `docker compose pull` and `docker compose up -d` and re-verifies. BunkerWeb's Docker images are pulled from BunkerWeb's own registry; Morphit ships only the config, not the images.
Reasons you might NOT want BunkerWeb:
- **Small private instance with a single-operator audience** — the added complexity isn't worth the marginal defense.
- **Tor-only or Lokinet-only deployment** — squatters typically don't route through anonymity networks; the .onion path has natural friction (§38.6 item a) and adding a WAF in front of an onion service complicates the routing.
- **Resource-constrained VPS** (<1 GB RAM) — BunkerWeb + scheduler containers add ~150250 MB resident.
For everyone else, deploy it. Reasons it's the default recommendation:
- OWASP-Top-10 protection out of the box (SQL injection, XSS, path traversal, etc.) without writing nginx ModSecurity rules.
- Curated bot lists + behavioral detection layered on top of basic User-Agent blocking.
- Per-country / per-AS rate limiting in addition to per-IP.
- Built-in GeoIP, slow-loris guards, connection-rate limits.
- Single dashboard for HTTPS certs, request rate limits, country blocking, and OWASP rule tuning.
### Caching the update surface — `/service-worker.js` + `verify.json` (why the update snackbar may not appear)
The in-app **"Load it now" update snackbar** that prompts users to reload after you deploy depends on two files always being served **fresh**:
- **`/service-worker.js`** — the browser refetches it on every update check (the app registers the worker with `updateViaCache: 'none'`, so it never uses the *browser's* HTTP cache for it), and a byte-changed worker is exactly what raises the snackbar. The build embeds a per-deploy version hash, so the file changes on every release.
- **`verify.json`** — this build's version + per-file hash manifest. The app's belt-and-suspenders **update-version poll** and the **"About this instance" auto-verify** both read it.
`updateViaCache: 'none'` only stops the *browser* from answering from its own cache — it does **not** stop an upstream cache. If **BunkerWeb's edge cache (or any CDN in front) serves `/service-worker.js` stale**, the browser receives the *old* worker on the network fetch, no update is detected, and the snackbar never appears — on mobile **and** desktop. A stale `verify.json` likewise makes the app think it's already up to date.
**The shipped configs now mark both `no-cache`** so BunkerWeb and the browser always revalidate:
- `ops/bunkerweb/frontend/nginx.conf` (the container BunkerWeb proxies to) has `location = /service-worker.js` and `location = /verify.json` blocks emitting `Cache-Control: no-cache`, AND `docker-compose.yml` now **bind-mounts** that file into the container (the Dockerfile `COPY` is a baked fallback the mount overrides). So config changes deploy like build changes: pull the new source, then **restart** the container — `morphit-ops upgrade` already `docker restart`s it, so the no-cache blocks ride along on a normal upgrade with no image rebuild. **One caveat for instances deployed BEFORE the mount was added:** a plain restart won't attach a *new* volume to an already-running container, so run `docker compose up -d frontend` **once** (recreates it with the mount); after that, restarts/upgrades pick up config changes automatically. (Fresh installs get the mount from the first `docker compose up -d`.) The app-side update poll does NOT depend on any of this — it ships in the web build, which `morphit-ops upgrade` redeploys — so the update snackbar works after a normal upgrade regardless; the no-cache config additionally keeps the service worker itself fresh.
- `ops/nginx/web.conf` (bare-metal) has the equivalent blocks (with a full security-header re-emission, since `add_header` in a `location` drops inherited headers — see the comment there; the hand-paste example in `RUN-A-MORPHIT-NODE.md` uses the shorter `expires -1` form, which sets `no-cache` without that footgun).
BunkerWeb passes an upstream `Cache-Control: no-cache` through and honors it for its own caching, so the frontend-container block is sufficient — you do **not** need a separate BunkerWeb cache rule. (If you have explicitly enabled BunkerWeb's `USE_CACHE`, confirm it isn't configured to override upstream cache directives for these two paths.)
**Never let an edge cache override the indexer's `/v1/` cache headers.** The read API sets `Cache-Control` per response and the value is load-bearing. In particular, `GET /v1/profiles` (the batch profile lookup behind every avatar + display name) returns `public, max-age=90, stale-while-revalidate=60` only when EVERY requested account resolved to a profile row, and `no-store` when any requested account is absent — because an absent account is normally just indexer lag in the 12 block window after that account broadcast its profile op or signed up. If a proxy or CDN caches that negative response anyway, the affected users' display names fall back to `@account` and their avatars to the identicon, and (since the client's in-memory cache is cleared by a reload but the browser's disk cache is not) **a page refresh does not fix it** — only a hard reload does. Proxy `/v1/` through untouched.
> The app no longer relies *solely* on the service-worker byte-diff: the update-version poll compares `verify.json`'s deployed version to the running bundle's version on every tab-foreground and surfaces the snackbar on a mismatch even if a proxy served the worker stale. The `no-cache` config above is still the right fix — it keeps the worker itself updating promptly and the poll cheap — but the two together mean the prompt appears regardless of proxy quirks.
**Beta gate note:** if you front the beta site with HTTP Basic Auth, **exempt `/verify.json`** from the auth (e.g. `auth_basic off;` inside its `location` block) so the version poll and auto-verify can read it. The poll sends the visitor's existing credentials, so it works through the gate either way, but the exemption is what lets the "About this instance" auto-verify succeed instead of reporting "Could not auto-verify."
#### If your reverse proxy serves the build directly (single-nginx topology)
The two shipped nginx configs above assume the **canonical two-container split** — BunkerWeb terminates TLS and reverse-proxies to a *separate* `frontend` nginx that serves the static build, and the no-cache blocks live in that frontend's `nginx.conf`. Some instances instead run a **single nginx that does everything**: it terminates TLS, serves `/usr/share/nginx/html` directly, AND proxies `/relay/`, `/v1/`, `/rss/` to the host. (Tell-tale: `docker ps` shows one edge container bound to `:80` and `:443` and there is no separate `frontend` service; `docker exec <edge> nginx -T` shows `root /usr/share/nginx/html;` alongside your `proxy_pass` blocks.) In that topology the shipped `ops/bunkerweb/frontend/nginx.conf` is **not** the file serving `/service-worker.js` and `/verify.json`, so its no-cache blocks never apply — and the update prompt silently breaks even though the repo "has" the fix. This is the single most common way a hand-rolled node ends up with a stale-worker problem.
Put the blocks in **the server config that actually serves the build** — the same one holding your `location /`, `/relay/`, and `/v1/` blocks — inside the port-443 `server { … }`:
```nginx
location = /service-worker.js {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
location = /verify.json {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
```
**Header-inheritance caveat.** The instant you add ANY `add_header` inside a `location`, nginx stops applying the `add_header` directives set higher up — so a bare version of the blocks above strips the CSP and HSTS headers from those two responses. If your server block sets CSP/HSTS via `add_header`, re-emit them inside each block so the two paths stay identical to every other response:
```nginx
location = /service-worker.js {
# re-emit the SAME Content-Security-Policy + Strict-Transport-Security
# add_header lines your server block already uses (copy them verbatim), then:
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
```
(For a script file and a small JSON blob the missing CSP/HSTS is low-risk — the document's CSP and the domain's HSTS ride on the real navigations — but re-emitting is tidy and avoids surprises in a header audit.)
Then reload the container so nginx re-reads the config, and **verify the headers flipped** — this is the confirmation the fix is live:
```
docker exec <your-edge-container> nginx -t # syntax check before restarting
docker restart <your-edge-container>
curl -sI https://yourdomain.com/service-worker.js | grep -i cache-control
curl -sI https://yourdomain.com/verify.json | grep -i cache-control
```
Both must show `cache-control: no-cache`. Until they do, a stale service worker can persist on a visitor's device indefinitely and they never receive a deploy until they hard-refresh — which almost no one does.
### What BunkerWeb adds on top of Caddy/nginx
| Feature | Caddy / nginx alone | BunkerWeb |
|---|---|---|
| HTTPS auto-renewal | Caddy yes; nginx via certbot | yes (built-in) |
| OWASP Top-10 rules | nginx via ModSecurity (manual config); Caddy via plugins | built-in |
| Bot detection | basic User-Agent blocking | curated bot lists + behavioral |
| Rate limiting | per-IP, per-route | per-IP, per-route, per-country, per-AS |
| GeoIP / country blocking | manual | built-in |
| DDoS mitigation | per-IP rate limit only | per-IP + connection-rate + slow-loris + body-size guards |
| Web UI | none | yes |
Each layer is additive to Morphit's own defenses (signup-drain stack §18, indexer abuse mitigation, etc.). BunkerWeb does NOT replace any of those — it's a perimeter shield that catches generic web attacks before they reach the Morphit stack at all.
### Architecture choice
**Option A — BunkerWeb instead of Caddy.** Simplest, and what `ops/bunkerweb/` ships: BunkerWeb terminates TLS and proxies everything to the `frontend` nginx container (canonical topology above), which serves the static build + proxies the API paths to the indexer/relay on the host. Use this if you're starting fresh.
**Option B — BunkerWeb in front of Caddy.** Use if you're already running Caddy and want to add WAF without changing the existing config. Slightly higher latency (~1-2ms per request from the second hop). Caddy listens on `127.0.0.1:8443`, BunkerWeb proxies to it from port 443.
### Linux install (Option A)
The official packages support Debian, Ubuntu, RHEL, Fedora. See https://docs.bunkerweb.io/latest/install for current install steps. After install, the configuration shape:
```yaml
# /etc/bunkerweb/variables.env (operator-tuned)
SERVER_NAME=morphit.example.com
USE_REVERSE_PROXY=yes
REVERSE_PROXY_URL=/
REVERSE_PROXY_HOST=http://127.0.0.1:3000 # the apps/web dev server, OR
# the static-build path if served by nginx
AUTO_LETS_ENCRYPT=yes
EMAIL_LETS_ENCRYPT=you@example.com
USE_BLACKLIST=yes
USE_DNSBL=yes
USE_LIMIT_REQ=yes
LIMIT_REQ_RATE=10r/s
USE_BAD_BEHAVIOR=yes
USE_MODSECURITY=yes
MODSECURITY_CRS_VERSION=4
USE_ANTIBOT=auto # serves a JS challenge to suspicious clients
```
Tune `LIMIT_REQ_RATE` for your traffic. Default `10r/s` is conservative — typical Morphit instances see a handful of orderbook fetches per second per browser, which fits.
### Docker install
If you're already running Morphit in Docker (see §33), BunkerWeb has an official `bunkerweb/bunkerweb` image. Compose snippet:
```yaml
services:
bunkerweb:
image: bunkerweb/bunkerweb:1.6
ports:
- "80:8080"
- "443:8443"
environment:
SERVER_NAME: "morphit.example.com"
USE_REVERSE_PROXY: "yes"
REVERSE_PROXY_HOST: "http://web:3000"
AUTO_LETS_ENCRYPT: "yes"
EMAIL_LETS_ENCRYPT: "you@example.com"
USE_BAD_BEHAVIOR: "yes"
USE_MODSECURITY: "yes"
depends_on:
- web
```
### Tuning for Morphit specifically
Morphit serves three classes of traffic that need slightly different rules:
1. **Browser GET to /, /orderbook, /post**, etc. — normal web pages. Default ModSecurity + bot detection works.
2. **JSON API to /relay/v1/*, /v1/*** — rate limits should be MORE generous because a single browser may make 3-10 calls per second during normal use. Recommend `LIMIT_REQ_RATE=30r/s` and exclude these paths from the JS-challenge antibot (a JS challenge breaks JSON API calls).
3. **Server-Sent Events at the indexer's `/v1/.../stream` endpoints** (`/v1/orderbook/stream`, `/v1/chat/:a/:b/stream`, `/v1/instances/stream`) — long-lived connections. ModSecurity must NOT inspect the streaming response body (it'll buffer and break the SSE), and rate limiting must be by NEW connection, not by ongoing connection.
Sample BunkerWeb config carving these out (paste in `variables.env`):
```yaml
# Default rules: conservative for browser traffic
LIMIT_REQ_RATE=10r/s
USE_ANTIBOT=auto
# Per-URL overrides for the API + SSE
LIMIT_REQ_URL_1=/relay/v1
LIMIT_REQ_RATE_1=30r/s
LIMIT_REQ_URL_2=/v1
LIMIT_REQ_RATE_2=30r/s
ANTIBOT_IGNORE_URI=/relay/v1 /v1
# SSE: don't buffer the indexer's streaming responses. BunkerWeb
# matches *_URL_* values as a regex, so this covers all three
# stream endpoints (orderbook / chat / instances).
USE_PROXY_BUFFERING_3=no
PROXY_BUFFERING_URL_3=^/v1/.*/stream$
```
### What BunkerWeb does NOT do
- It does NOT prevent name-squatting on Blurt — that's the protocol's account-creation cost (~100 BLURT) plus Morphit's signup-drain stack (§18). Note that **the relay pays this cost**, not the attacker — every signup costs your relay ~100 BLURT regardless of who's behind it. BunkerWeb stops a *web bot* hitting `/v1/account/create` 1000 times a minute, but a determined attacker who's willing to burn through your relay's balance one signup at a time is, by design, allowed to create accounts up to the daily ceiling.
- It does NOT replace the operator-balance alert scanner (§16). A drain attack that's expensive enough to bypass rate limits will still hit that alarm.
- It does NOT replace `db-password-placeholder-smoke` (§30) or the rest of the static smoke suite.
### Where to monitor
```sh
journalctl -u bunkerweb.service -f
# Or via the web UI at https://your-host:7000 (if you enabled it)
```
`USE_REAL_IP=yes` is also worth setting — without it, all your indexer/relay logs show BunkerWeb's IP, not the user's, making downstream debugging harder.
### CRITICAL: trusted-proxy IPs for BunkerWeb deployments
Out of the box, the relay only trusts `X-Forwarded-For` headers from loopback addresses (`127.0.0.1`, `::1`). This is correct for the canonical single-host nginx topology where nginx and the relay run side-by-side and connect via loopback. **It is WRONG for BunkerWeb deployments** in several common topologies:
| Topology | Relay sees socket peer as | What happens without config |
|---|---|---|
| BunkerWeb in Docker compose alongside the relay | Docker bridge IP (e.g., `172.18.0.5`) | All signups from BunkerWeb users share ONE rate-limit bucket — one abuser exhausts the daily limit for everyone |
| BunkerWeb on a separate host from the relay | BunkerWeb's host IP (e.g., `10.0.0.5`) | Same — every user shares one bucket |
| BunkerWeb in front of nginx (Option B) on same host | Loopback (nginx is the trusted hop) | OK — nginx already trusted; X-Forwarded-For chain works |
To fix the Docker-compose case, set `MORPHIT_RELAY_TRUSTED_PROXY_IPS` to the Docker bridge CIDR.
**If you deploy the canonical morphit-shipped BunkerWeb compose** (`ops/bunkerweb/docker-compose.yml`, also deployed by the Ansible `bunkerweb` role), the CIDR is **PINNED at `172.20.0.0/16`** — set:
```
MORPHIT_RELAY_TRUSTED_PROXY_IPS=172.20.0.0/16
```
The Ansible playbook's group_vars default already sets this. The compose was deliberately pinned to `172.20.0.0/16` (instead of letting Docker auto-assign) precisely so this CIDR is stable and operators can hard-code it without re-inspecting after rebuilds. In the canonical topology the relay's immediate socket peer is the `frontend` container (requests flow BunkerWeb → frontend → relay), but BOTH containers live on `172.20.0.0/16`, so the single pinned CIDR is all the relay needs; BunkerWeb sets the real client as the leftmost `X-Forwarded-For` entry and the `frontend` appends to the chain, so the relay reads the real client from `XFF[0]`.
**If you deploy your OWN compose** with a different network CIDR, the default Docker bridge networks are typically `172.17.0.0/16` (the default `bridge` network) and `172.18.0.0/16` through `172.31.0.0/16` for user-defined networks. To find YOUR bridge network's CIDR:
```sh
docker network inspect <your-compose-network> --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
# Example output: 172.18.0.0/16
```
Then set it in the relay's environment:
```ini
# /etc/morphit/relay.env (or your systemd Environment= directive)
MORPHIT_RELAY_TRUSTED_PROXY_IPS=172.18.0.0/16
```
For multi-host BunkerWeb (BunkerWeb on a separate machine):
```ini
# Pass the BunkerWeb host's actual IP, NOT a CIDR (the host
# is one fixed address, not a range)
MORPHIT_RELAY_TRUSTED_PROXY_IPS=10.0.0.5
```
For multiple proxies (e.g., BunkerWeb in Docker + a CDN in front of it):
```ini
# Comma-separated. Each entry is either a bare IP or a CIDR.
MORPHIT_RELAY_TRUSTED_PROXY_IPS=172.18.0.0/16,10.0.0.5
```
**SECURITY WARNING.** This is the most dangerous knob in the relay's configuration. Setting it too broad — e.g., `0.0.0.0/0`, or a CIDR that covers more than your actual proxy — lets ANY remote client forge `X-Forwarded-For` to bypass per-IP rate limits and drain the relay's BLURT. Always pass the NARROWEST CIDR that covers your actual proxy. The relay logs `trusted_proxies_configured` at boot with the parsed exact-count and CIDR-count; verify it matches your expectation. Malformed entries (like a typo `172.18.0.0/3X`) are logged as `trusted_proxies_some_rejected` — an operator who sees that line in their boot log should fix their config immediately.
To verify the trust chain is working end-to-end after configuring, hit any rate-limited endpoint from two different real client IPs (your phone on cell + your laptop on Wi-Fi) and confirm the relay logs show DIFFERENT bucket keys per request. If both show the proxy's IP, the trust chain is broken.
### Compatibility with §37 server hardening
BunkerWeb interacts with several of §37's hardening directives. Check these before deploying both:
**§37.1 SSH hardening** — No conflict. BunkerWeb listens on 80/443; SSH is on 22. The two never touch the same port.
**§37.4 Mount hardening (`noexec` on /tmp).** If you run BunkerWeb in Docker, `noexec` on `/tmp` is fine because Docker's overlay filesystem isolates the container's `/tmp` from the host's. If you run BunkerWeb directly via `apt` package, BunkerWeb's nginx config-test step (`bw-cli` and ModSecurity rule compilation) may require executing scripts in `/tmp`. If `bw-cli` errors out citing permission-denied on /tmp scripts, temporarily remount /tmp executable for the upgrade window (see §37.4 for the procedure).
**§37.5 Systemd hardening.** BunkerWeb's official systemd unit ships with reasonable isolation defaults. If you've applied a custom `hardening.conf` drop-in to ALL services, audit BunkerWeb's drop-in too — `ProtectSystem=strict` is fine but `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6` MUST allow `AF_NETLINK` if BunkerWeb's traffic-shaping plugin is loaded (it queries iptables via netlink).
**§37.13 Outbound egress allowlist.** BunkerWeb makes outbound connections to: Let's Encrypt (TCP 80 + 443), Maxmind GeoIP database updates (TCP 443), and DNSBL queries (TCP/UDP 53). If you applied the relay-host egress allowlist, those work. The BunkerWeb-bot-database refresh (`USE_BAD_BEHAVIOR=yes` enables a daily download from the BunkerWeb cloud) hits 443; also fine. If you see `geoip_update_failed` or `dnsbl_update_failed` in BunkerWeb logs, your egress policy is the likely culprit.
**§34 fail2ban.** Most fail2ban rules watch `/var/log/auth.log` (SSH) — no conflict. If you've added a Morphit-specific fail2ban rule that watches the relay's HTTP error log for `429`s, double-check the log format: BunkerWeb's nginx writes a different format than stock nginx. The simpler approach is to write fail2ban rules against BunkerWeb's own logs (`/var/log/bunkerweb/access.log` and `/var/log/bunkerweb/error.log`) which include the original client IP via `USE_REAL_IP=yes`.
**§37.8 Postgres hardening.** No interaction. BunkerWeb doesn't touch the Postgres port.
### Advanced WAF rule tuning for Morphit
BunkerWeb's default ModSecurity ruleset (CRS 4.x) catches OWASP Top-10 attacks. Morphit's signup endpoints have a few characteristics that benefit from tuning beyond the defaults:
**1. Whitelist the signup endpoint's request body.** Morphit's `/v1/account/create` body contains four base58-encoded BLT-prefixed public keys. CRS rule 920420 ("Request content type is not allowed") might false-positive on `application/json` bodies that look unusual to it. Add to BunkerWeb's variables.env:
```yaml
# Whitelist Morphit's signup endpoints from CRS rules that
# false-positive on legitimate signed-op payloads.
MODSECURITY_CRS_BLACKLIST_RULES=920420 921120 942100
# 920420 — content type whitelist (JSON acct-create body)
# 921120 — HTTP request smuggling (paranoid mode)
# 942100 — SQL injection regex (matches BLT-prefixed keys)
```
You can add per-URI whitelists rather than disabling rules globally:
```yaml
# Disable rules ONLY for /v1/account/create
MODSECURITY_CRS_REMOVE_RULES_BY_URI=^/v1/account/create$ 920420 921120 942100
```
**2. Tighten the rate limits for `/v1/account/invite` specifically.** The invite endpoint is where the real-money cost lives. BunkerWeb's per-URI rate limit can be MUCH tighter here than the default:
```yaml
# Default: 30/s for the JSON API surface
LIMIT_REQ_RATE=30r/s
# Per-URI override: invite endpoint specifically. 3/s is plenty
# for any real user (they call it once per signup).
LIMIT_REQ_URL_3=/v1/account/invite
LIMIT_REQ_RATE_3=3r/s
LIMIT_REQ_BURST_3=10
```
**3. Block known malicious IP ranges via the BunkerWeb blacklist.** If you've identified attacker /24s from previous incidents, add them:
```yaml
# Comma-separated IP / CIDR list of permanently-blocked sources
BLACKLIST_IP=203.0.113.0/24,198.51.100.50
```
**4. GeoIP-block from countries with disproportionate squatter activity.** This is operator's-call — Morphit is a worldwide service, blocking entire countries denies access to legitimate users. But if you're in incident response and an attacker is concentrated in one CC, you can:
```yaml
USE_GEOIP=yes
BLACKLIST_COUNTRY=XX YY # ISO-3166 alpha-2 codes
```
Reverse the policy if you want to RESTRICT to specific countries (rare for Morphit):
```yaml
USE_GEOIP=yes
WHITELIST_COUNTRY=US CA GB DE FR
```
**5. Per-AS rate limiting.** Cloudflare-tier defense. BunkerWeb supports per-ASN rate limits with the appropriate plugin; if you're seeing a coordinated drain from a single autonomous system (a cheap cloud provider's abuse-friendly hosting), you can rate-limit by AS rather than by IP:
```yaml
USE_LIMIT_REQ_BY_ASN=yes
LIMIT_REQ_BY_ASN_RATE=5r/m
```
Tune the rate to your traffic — too tight breaks legitimate users from large ISPs whose ASN you'd inadvertently throttle.
**6. Enable BunkerWeb's antibot challenge for `/v1/account/invite` only.** The default `USE_ANTIBOT=auto` covers everything, but the Morphit user flow is sensitive: a JS challenge during the invite step is fine because the browser is in "click-button" mode there, not in active API conversation. Don't enable antibot on `/v1/account/create` (the second-step submit) — by then the user has already solved Altcha and another challenge would be confusing.
```yaml
USE_ANTIBOT=auto
ANTIBOT_IGNORE_URI=/v1/account/create /relay/v1 /v1
```
**7. Bigger request-body limit ONLY for canary verification.** The canary endpoint at `/canary.txt` returns a few KB; defaults are fine. But if you ever serve large operator-disclosure documents (e.g., `/operator-disclosure.pdf`), bump `MAX_CLIENT_SIZE` to allow them:
```yaml
MAX_CLIENT_SIZE=10m
# For specific URIs:
MAX_CLIENT_SIZE_URL_1=/disclosures
MAX_CLIENT_SIZE_RATE_1=20m
```
### When to NOT add BunkerWeb
BunkerWeb is excellent for medium-traffic instances. It's overkill for:
- **A small private instance** (under ~50 daily signups). Caddy's built-in TLS + Morphit's own rate limits are sufficient.
- **Tor-only / I2P-only deployments.** Those run behind their respective network-layer protections; an additional WAF in front is mostly redundant and adds attack surface.
- **Resource-constrained VPS** (under 1GB RAM). BunkerWeb's nginx + ModSecurity + lua plugins use ~200-400MB resident; on a 1GB host that's a meaningful fraction of your headroom.
For those cases, stick with the Caddy-based default and tune Morphit's own §18 layers.
---
## 33. Docker deployment — optional alternative to bare-metal
The default install in `RUN-A-MORPHIT-NODE.md` is bare-metal: clone the repo, install Node.js, run `npm install`, run `systemctl enable` on the unit files. **That's the recommended path** for first-time operators because there's no extra abstraction layer to debug when something's wrong. This section is for operators who already use Docker for everything else and want consistency with their existing fleet.
### What Docker buys you
- **Consistent environment** — Node version, libsodium, Postgres client all pinned in the image.
- **Easier rollback** — `docker compose down && docker compose up -d --pull=always` swaps the running version without touching the host.
- **Isolation** — the indexer/relay run in containers; a compromise of those processes doesn't touch the host filesystem (with proper volume scoping).
### What Docker costs you
- **Slightly more complex backups** — the DB volume needs to be in your backup path, not just `/var/lib/postgresql`.
- **One more thing to keep updated** — the base image, in addition to the host OS and the Morphit code.
- **Networking hops** — adds 1-3 ms per request unless you use `network_mode: host`.
### Compose example
There's no `docker-compose.yml` shipped in the repo (deliberately — Docker is one of several deployment options, not the canonical one). Below is a tested-shape reference. Drop into the repo root or a sibling dir.
> **Caveat (cp308 audit).** The two classes of secret reference in the example
> below behave differently — read carefully before copying:
>
> - **DB password from a file is NOT yet implemented.**
> `MORPHIT_INDEXER_DB_PASSWORD_FILE` and `MORPHIT_RELAY_DB_PASSWORD_FILE` are
> ignored today; the services read the password directly from
> `MORPHIT_INDEXER_DATABASE_URL` / `MORPHIT_RELAY_DATABASE_URL`. Until the
> `*_FILE` pattern lands, **inline credentials in the DATABASE_URL** for the
> Compose example to work — e.g.
> `postgres://morphit_indexer:<password>@postgres:5432/morphit_indexer`.
> Implementation tracked in REVISIT-LIST.
> - **The relay key + passphrase from a file ARE implemented** and work exactly
> as shown. `MORPHIT_RELAY_ACTIVE_KEY_FILE` is **required** and reads the
> encrypted keystore from the mounted secret path; `MORPHIT_RELAY_ACTIVE_KEY_PASSPHRASE_FILE`
> reads the unlock passphrase from its mounted secret. (Earlier revisions of
> this example named these `MORPHIT_RELAY_KEYSTORE_PATH` /
> `MORPHIT_RELAY_PASSPHRASE_FILE`, which the relay never reads — copying that
> would set ignored vars, omit the required `…ACTIVE_KEY_FILE`, and the relay
> would fail to boot.)
```yaml
# docker-compose.yml
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: morphit_indexer
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: morphit_indexer
secrets:
- db_password
volumes:
- pgdata:/var/lib/postgresql/data
# Backup mount: the morphit-backup.sh script (§31) runs
# ON THE HOST, but pg_dump connects via TCP to the
# container. Make sure the container's port 5432 is
# bound to 127.0.0.1 only.
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U morphit_indexer"]
interval: 10s
timeout: 5s
retries: 5
indexer:
build:
context: .
dockerfile: apps/indexer/Dockerfile
restart: unless-stopped
environment:
MORPHIT_INDEXER_DATABASE_URL: "postgres://morphit_indexer@postgres:5432/morphit_indexer"
MORPHIT_INDEXER_DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
depends_on:
postgres:
condition: service_healthy
ports:
# Indexer's loopback HTTP port — must match the
# MORPHIT_INDEXER_LISTEN_PORT default of 8081. nginx /
# Caddy / BunkerWeb upstream points at 127.0.0.1:8081.
- "127.0.0.1:8081:8081"
relay:
build:
context: .
dockerfile: apps/relay/Dockerfile
restart: unless-stopped
environment:
MORPHIT_RELAY_DATABASE_URL: "postgres://morphit_indexer@postgres:5432/morphit_indexer"
MORPHIT_RELAY_DB_PASSWORD_FILE: /run/secrets/db_password
MORPHIT_RELAY_ACTIVE_KEY_FILE: /run/secrets/relay_keystore
MORPHIT_RELAY_ACTIVE_KEY_PASSPHRASE_FILE: /run/secrets/relay_passphrase
secrets:
- db_password
- relay_keystore
- relay_passphrase
depends_on:
postgres:
condition: service_healthy
ports:
# Relay's loopback HTTP port — must match the
# MORPHIT_RELAY_LISTEN_PORT default of 8080.
- "127.0.0.1:8080:8080"
web:
build:
context: .
dockerfile: apps/web/Dockerfile
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
secrets:
db_password:
file: ./secrets/db_password
relay_keystore:
file: ./apps/relay/keystore.json
relay_passphrase:
file: ./secrets/relay_passphrase
volumes:
pgdata:
```
### Dockerfiles
The repo doesn't ship Dockerfiles (intentional — they're lightweight enough that operators who want them write them once for their fleet). A starter shape for `apps/relay/Dockerfile`:
```dockerfile
FROM node:22-alpine
RUN apk add --no-cache postgresql-client
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/relay/package.json apps/relay/
COPY packages/*/package.json packages/*/
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["npm", "start", "-w", "apps/relay"]
```
Same shape for `apps/indexer` and `apps/web` (the web one's CMD is `npm run start -w apps/web` after a build step).
### Backups in Docker
The `morphit-backup.sh` script from §31 still runs on the HOST, not inside a container. Update `backup.env`:
```sh
DB_NAME=morphit_indexer
DB_USER=morphit_indexer
PGHOST=127.0.0.1 # postgres container's port-mapped address
PGPORT=5432
```
Add `~/.pgpass` to the morphit system user's home with the same password as `secrets/db_password`. The systemd timer + service files are unchanged — they just connect via TCP instead of UNIX socket.
### What to NOT use Docker for
- **The wizard.** `morphit-ops init` is meant to run as the host operator user, write to the repo, and print sudo commands. Running it inside a container is a footgun (paths and permissions don't match the host's).
- **One-shot CLI commands** (`morphit-ops register`, etc.). Same reason.
These should run on the host with the same Node version your image uses, against the running container's exposed ports.
### Troubleshooting: a service won't start (use `morphit-ops doctor`)
When the indexer or relay exits immediately on start with a
configuration error — `config validation failed: … Required`,
`[operator-config] … not in the operator allowlist`, an empty/missing
key file, or a key-file permission complaint — the fastest path is
the read-only preflight:
```
cd /opt/morphit # the install directory
npx morphit-ops doctor # or: morphit-ops doctor, if symlinked onto PATH
```
What it does, and why it's trustworthy:
- It sources `morphit.env` exactly the way a real start does
(`set -a; . morphit.env; set +a`) and runs each service's **own**
config loader via an internal `--check-config` mode that loads
config and exits. Because it uses the services' real loaders, its
verdict cannot drift from what the services actually require.
- It reports `✓ will start` / `✗ will NOT start` per service, and for
a failure it surfaces the validation lines (not a stack trace) plus
the fix.
- It **mutates nothing** — no files, no database, no started
services, no network calls. The relay check runs *before* the
passphrase-unlock step, so it never prompts; instead it reports
whether the active key is plaintext or an encrypted envelope (i.e.
whether the relay will ask for a passphrase at real start).
- Exit code: `0` if both services validate, `1` if either fails, `2`
if you're not in an install directory. `--json` emits a structured
result for scripting.
This is the recommended first step before `systemctl start` on a
fresh install, and the first thing to reach for when a node that used
to boot suddenly won't after a config change. Note that `doctor`
validates *config*; it does not (and is not meant to) install or
start services or systemd units — that remains a manual/Ansible step.
`doctor` finishes with a read-only **security audit** (operator-only;
this is deliberately NOT exposed on the public `/v1/health` endpoint,
which would advertise a weak key to attackers). It reports:
- **Active-key encryption** — whether the relay key is an encrypted
envelope (✓) or stored in **plaintext** (⚠, with the `morphit-ops
edit-active-key` remediation). It detects this by reading only the
first bytes of the key file to spot the envelope marker; it never
prints key material. Reminder: encrypted keys are not auto-unlocked
(by policy), so the relay needs a manual passphrase at each start.
- **Active-key file permissions** — ⚠ if group/other-readable (the
relay also refuses to boot in that case).
- **Secret-file permissions** — ⚠ if `morphit.env` or
`morphit.config.env` is group/other-readable; `morphit.env` holds
the database password and is not permission-checked at boot, so
this catches a real at-rest leak.
Security findings are **advisory** — they do not change doctor's exit
code (which reflects boot-readiness), but a hardened instance should
show all green here. `--json` includes a `security` array.
### Troubleshooting: `morphit-ops` says "command not found"
If `npx morphit-ops init` (or `register`, `edit`, `upgrade`) worked once and then stopped — or never worked on a fresh clone — there are two causes, in order of how often they bite:
**Cause 1 (most common): you're not in the repo directory, or `npm install` hasn't populated `node_modules` yet.**
`morphit-ops` is a workspace-local tool — it is *not* published to the public npm registry; it lives in this repo under `apps/ops-cli/`. `npx` finds it only when you run from **inside the Morphit repo** (it searches upward from your current directory for the workspace) **and** after `npm install` has populated `node_modules` at the repo root. If you run it from your home directory, from a subdirectory outside the repo, or from a fresh clone where you haven't installed yet, `npx` finds no local tool, falls through to the public registry, and you see something like:
```
npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/morphit-ops - Not found
```
That E404 *is* the "command not found" — npx looked for a published package named `morphit-ops` (there is none — it's private to this repo).
The classic trap: yesterday you ran it from `~/morphit` and it worked; today after a `git pull` you happened to be in a different directory, or you're on a freshly-cloned second server where you haven't run `npm install` yet.
**Cause 2: the workspace bin symlink went stale.**
`npm install` creates a symlink at `node_modules/.bin/morphit-ops` pointing into the workspace. If a `git pull` changed `package.json` / `package-lock.json` / the workspace layout (this repo regenerates the lockfile at meaningful milestones), that symlink can be invalidated until you re-run `npm install`.
**Both causes have the same fix — run from the repo root, after installing:**
```
cd ~/morphit # wherever you cloned it
npm install # re-creates node_modules/.bin/morphit-ops
npx morphit-ops init # now resolves the local bin
```
The rule: **re-run `npm install` after every `git pull`.** The repo's update procedure (§12 here and `RUN-A-MORPHIT-NODE.md §12`) already does this for `npm run build`; the same `npm install` is what restores the `morphit-ops` bin.
Two more things worth knowing:
- **Run it from inside the repo.** `npx` searches upward from your current directory for `node_modules/.bin`. If you `cd` somewhere outside the Morphit tree first, it won't find the local bin. Always run `morphit-ops` from the repo root.
- **It needs `tsx`.** The CLI runs from TypeScript source via `tsx`, which is a **production dependency** of `apps/ops-cli` (since cp161 — previously a devDependency, which broke the CLI under `NODE_ENV=production` or `npm install --omit=dev`). A plain `npm install` at the repo root installs it. If you deliberately install with `--omit=dev`, `tsx` is still present because it is a production dep.
If `npm install` doesn't fix it, you can bypass the symlink entirely and invoke the workspace directly:
```
npm exec --workspace apps/ops-cli morphit-ops -- init
```
or
```
cd apps/ops-cli && npm start -- init
```
Both run `tsx src/main.ts init` against the local source without relying on the root `node_modules/.bin` symlink.
**Ansible operators:** the playbook (`ops/ansible/`) handles `npm install` for you on each run, and since cp161 it verifies the `morphit-ops` bin is runnable as a post-install step — so a broken install fails the play with a clear error rather than surfacing later. If you re-deploy after a repo change, re-run the playbook; don't `git pull` on the target host out-of-band.
---
## 34. UFW firewall + fail2ban — extended hardening
`RUN-A-MORPHIT-NODE.md §5` covers the basic UFW setup (allow 22, 80, 443; enable). This section is for operators who want to go further.
### Default-deny inbound, default-allow outbound
The default Ubuntu UFW config is already this. Verify:
```sh
sudo ufw status verbose
# Default: deny (incoming), allow (outgoing), disabled (routed)
```
If the inbound default is anything else, fix it:
```sh
sudo ufw default deny incoming
sudo ufw default allow outgoing
```
### SSH rate-limit instead of plain allow
Plain `ufw allow 22/tcp` lets an attacker hammer the SSH login forever. Better:
```sh
sudo ufw delete allow 22/tcp 2>/dev/null
sudo ufw limit 22/tcp comment 'SSH (rate-limited: 6 attempts in 30s)'
```
`limit` invokes the kernel's connection-tracking rate limiter — 6 connections in 30 seconds from any single IP, after which UFW drops the packets entirely (no TCP RST, no auth attempt).
### IPv6
Most VPS providers give you both v4 and v6. UFW manages both by default; verify:
```sh
grep IPV6 /etc/default/ufw
# Expected: IPV6=yes
```
If `no`, edit and `sudo ufw reload`.
### Postgres must NOT be exposed to the public internet
The indexer and relay connect to Postgres over `localhost:5432`.
**Postgres should never accept connections from anything else.**
Stock Ubuntu 24's `postgresql-contrib` package defaults to
`listen_addresses = 'localhost'`, which is correct. But if
you've installed Postgres a different way — Docker, a custom
image, a PaaS that gives you a public-by-default cluster, or
a manually-edited `postgresql.conf` — verify before booting
the indexer:
```sh
sudo -u postgres psql -t -c "SHOW listen_addresses;"
# Expected: 'localhost' or '127.0.0.1'
# DANGER: '*' or '0.0.0.0' (publicly reachable)
```
If it's wrong, fix it in `/etc/postgresql/*/main/postgresql.conf`
(the `*` glob picks the active major version — typically `17` on
Ubuntu 24, `16` on 22.04, etc.):
```
listen_addresses = 'localhost'
```
then `sudo systemctl restart postgresql`.
Also verify UFW (or whatever firewall you use) explicitly
denies inbound 5432:
```sh
sudo ufw status verbose | grep 5432
# Expected: nothing (UFW's default-deny policy already blocks it)
# DANGER: "5432/tcp ALLOW Anywhere"
```
If a previous step accidentally exposed it:
```sh
sudo ufw delete allow 5432/tcp
sudo ufw deny 5432/tcp comment 'morphit DB — never public'
```
The same applies to Docker: `127.0.0.1:5432:5432` not
`5432:5432` — the latter binds to all interfaces (see §33
Docker compose example, which gets this right).
### fail2ban — second-layer SSH defense
UFW's rate limit catches the noisy attempts; fail2ban catches the patient ones (one attempt every 31 seconds). Default install on Ubuntu 24:
```sh
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
```
The default `/etc/fail2ban/jail.d/defaults-debian.conf` only enables the `sshd` jail. Verify:
```sh
sudo fail2ban-client status
sudo fail2ban-client status sshd
```
A bantime of 10 minutes / 5 retries is the default. Increase for a public-facing operator instance:
```sh
sudo tee /etc/fail2ban/jail.d/morphit.local <<'EOF'
[sshd]
enabled = true
bantime = 1h
findtime = 10m
maxretry = 3
EOF
sudo systemctl restart fail2ban
```
### fail2ban for the relay — NOT VIABLE without breaking IP privacy
You might want to extend fail2ban to ban IPs that hammer
`/v1/account/create` after the per-IP spacing layer (§18 layer 3)
has already rejected them. **This isn't possible with the relay's
default logging stance.**
The `access_log` middleware (`apps/relay/src/middleware/access_log.ts`)
is documented as an explicit no-IP-logging surface — see its
header comment, which references the PHASE-3a-DESIGN.md privacy
commitment. Without IP addresses in the relay's structured logs,
fail2ban has nothing to extract a `<HOST>` from.
This is a **deliberate, per-operator-choice tradeoff**:
- **Default (privacy-preserving):** the relay logs request method,
path, status, duration, and rejection code (e.g.
`code=spacing_cooldown`). No IP. fail2ban-for-relay is
unavailable, but operators are not building a per-IP signup
history that could be subpoenaed or leaked.
- **Custom build (operator opt-in):** if an operator chooses to
log IPs for their instance — for example, on a dedicated
high-volume frontend instance where banning persistent abusers
is more important than IP privacy — they can fork
`access_log.ts` to include `ip: clientIp` in the structured
context. The fail2ban filter regex would then need to match
the actual structured log shape:
```
failregex = ^.*\[access\] request .*ip=<HOST>.*code=spacing_cooldown.*$
```
Note the module is `[access]` (not `[signup-spacing]`) and the
code is `spacing_cooldown` (not `too_soon`).
**Recommended path for most operators:** rely on §18 layers 1-4
(global daily ceiling, per-IP daily cap, per-IP spacing, signed
invite tokens) to bound abuse — these all run inside the relay
and don't depend on log shape. Save fail2ban for SSH and other
non-relay surfaces where IP-banning is unambiguously appropriate.
UFW's `limit` keyword (covered earlier in this section) gives
you SSH-style brute-force protection that doesn't need IP logs:
the kernel's connection-tracking rate-limiter drops packets at
the network layer when an IP exceeds the rate, with no
application visibility required. Apply the same idea to port
443 if a particular IP is hammering the relay's HTTPS
endpoint.
### Lockout-prevention — don't lock yourself out
Before turning on aggressive `fail2ban` rules, make sure your own admin IP is exempt:
```sh
sudo fail2ban-client set sshd addignoreip <your.admin.ip>
# Persistent: edit /etc/fail2ban/jail.d/morphit.local and add:
# [DEFAULT]
# ignoreip = 127.0.0.1/8 ::1 <your.admin.ip>
```
---
## 35. TLS auto-renewal — quick reference
The full TLS section is in §14.5 above (Let's Encrypt + nginx). This is the operator-facing summary card.
### If you used Caddy (the recommended path)
You don't need to do anything. Caddy renews automatically. Verify:
```sh
sudo journalctl -u caddy.service | grep -i 'certificate.*obtained\|renewed'
```
Should see entries every 60 days or so per domain.
### If you used nginx + certbot
Verify the timer is on:
```sh
systemctl list-timers | grep certbot
# Expected: certbot.timer active ...
```
If not, enable:
```sh
sudo systemctl enable --now certbot.timer
```
### If you used BunkerWeb
`AUTO_LETS_ENCRYPT=yes` in `variables.env` (§32) handles it. Verify:
```sh
sudo journalctl -u bunkerweb.service | grep -i 'certificate'
```
### Quarterly verification
Once a quarter, verify the cert chain on your live domain:
```sh
echo | openssl s_client -servername morphit.example.com -connect morphit.example.com:443 2>/dev/null \
| openssl x509 -noout -dates -subject -issuer
```
Expected: `notAfter` is at least 30 days in the future. If less than 30 days and there's no recent renewal in your reverse-proxy logs, something's wrong with auto-renewal — investigate before it expires.
### What to do if auto-renewal breaks
Symptoms: cert expires, browsers show NET::ERR_CERT_DATE_INVALID, your monitoring (you DO have monitoring, right?) fires.
Manual renewal:
```sh
# Caddy: systemctl reload caddy
# nginx + certbot: certbot renew --force-renewal && systemctl reload nginx
# BunkerWeb: the bunkerweb container's healthcheck triggers renewal on next interval
```
Then investigate WHY auto-renewal broke. Most common causes:
1. **DNS A record changed** — the new IP doesn't match what the renewal HTTP-01 challenge expects.
2. **Port 80 firewalled** — HTTP-01 challenge requires inbound 80 from Let's Encrypt's network.
3. **certbot's deploy-hook missing** — cert was renewed but the proxy never reloaded. See §14.5 for the fix.
4. **Rate limit hit** — Let's Encrypt limits 50 certs/week/registered-domain. If you're testing renewal repeatedly, you can hit this.
## 36. Warrant canary — weekly automated regeneration
### What this is (plain language)
A **warrant canary** is a small signed text file your instance
publishes at `https://YOUR-DOMAIN/canary.txt`, re-published every
week. In it you state, in plain words, that nobody has secretly
forced you to spy on users or weaken Morphit (no NSL, no FISA
order, no gag order, no backdoor demand).
The trick: in many places the law can force you to spy AND forbid
you from saying so — but it generally cannot force you to keep
*actively lying* on a schedule. So if you are ever served such an
order, you simply **stop updating the canary**. After 14 days of
staleness, users (or a watchdog) who read the canary's `Generated:`
date treat it as silent and move to another instance. You never
have to say a word.
So your only ongoing job is boring: keep the file fresh — you
re-sign it about weekly **on your own machine and upload it to
the server.** DO NOT run this from a cron job on the server: a
seized box would keep auto-signing "all-clear" canaries forever,
silently turning your canary into a lie. Signing off-server is
what makes it a real dead-man's switch — it goes stale exactly
when it should (you're compelled and gagged, something happens to
you, or the box is seized without your key). The file is
**PGP-signed by your canary key** (which lives on your machine,
never on the server) and carries freshness proofs (the current
Blurt chain head, the current Bitcoin chain head, and a current
news headline) so readers can confirm it was generated recently
and not replayed from an old copy.
### Setup (done once — ON YOUR OWN MACHINE)
Do all of this on the machine you'll sign from (e.g. your laptop) —
the one place your canary PGP key lives. You only touch the server
in step 5, to upload the finished files.
1. **Make (or pick) a PGP signing key** on your machine. If you
already have a release/canary key here, skip to step 2.
```sh
gpg --quick-gen-key 'Morphit Operator <op@morphit.example>' \
ed25519 sign 5y
```
Note the **fingerprint** from the output — you will need it.
2. **Export your PGP public key** so users can verify signatures
without contacting a key server (you upload it in step 5):
```sh
FINGERPRINT="<from step 1>"
gpg --armor --export "$FINGERPRINT" > /tmp/pgp_keys.asc
```
3. **Set the canary settings** in your shell (or a local file you
`source`). All four are required:
```sh
export MORPHIT_CANARY_PGP_KEY_ID="<your PGP fingerprint>"
export MORPHIT_CANARY_OPERATOR_NAME="<display name, e.g. morphit.io>"
export MORPHIT_CANARY_INSTANCE_ORIGIN="https://morphit.example.com"
# Only shown in the canary text to identify which operator it is —
# your relay account (MORPHIT_RELAY_ACCOUNT), e.g. morphit-relay.
# No key is needed for it; the canary is signed only with PGP.
export MORPHIT_CANARY_OPERATOR_ACCOUNT="<your relay account, e.g. morphit-relay>"
# Optional — sensible defaults are used if you omit these:
# The Blurt chain-head fetch fails over across the whole rotator
# list (DEFAULT_BLURT_RPC_ENDPOINTS) by default, so a single dead
# node won't stall the canary. Set this ONLY to pin one node on
# purpose (leaving it unset is what you want):
# export MORPHIT_CANARY_BLURT_RPC="https://rpc.blurt.one"
# export MORPHIT_CANARY_NEWS_RSS="https://cointelegraph.com/rss"
```
**No Blurt private key is involved** — the canary is signed only
with your PGP key, which gpg manages. If you keep these in a
file, `chmod 600` it and never commit it.
4. **Sign the canary** from your local repo checkout:
```sh
cd ~/path/to/morphit # your local checkout
bash scripts/canary/generate.sh
```
Success ends with `canary: wrote .../apps/web/static/canary.txt`
— the freshly-signed file on YOUR machine. It is NOT live yet:
nginx on the server serves from `build/`, not `static/`. Step 5
puts it where it's served.
5. **Upload the signed files to the server's served dir**, then
confirm the signature over the live URL:
```sh
# copy up to a temp spot first (the served dir is root-owned),
# then move both into place on the server:
scp apps/web/static/canary.txt /tmp/pgp_keys.asc you@your-server:/tmp/
ssh you@your-server \
'sudo cp /tmp/canary.txt /tmp/pgp_keys.asc /opt/morphit/apps/web/build/'
# verify it's live + valid (your public key must be in your keyring):
curl https://morphit.example.com/canary.txt | gpg --verify
# Should report: Good signature from "Morphit Operator <op@...>"
```
nginx serves the new files immediately — no restart needed.
6. **Keep it fresh — and mind two gotchas:**
- **Re-sign + re-upload about weekly** (repeat steps 45); set a
recurring reminder somewhere you'll see it. If you're ever
compelled to spy on users, you simply stop — after 14 days a
stale `Generated:` date is the signal users (or a watchdog) act
on (there is no automatic banner; see the note below).
- **Re-upload after every `morphit-ops upgrade`.** An upgrade
rebuilds `build/`, which wipes the canary. If you sign on the
**same box** you serve from, the upgrade re-runs your refresh
(`~/.morphit/update-canary.sh`) and restores it for you. If you
sign on a **separate machine** (the recommended posture), the
upgrade prints a reminder and `morphit-ops health` shows the
canary "missing" (red) until you re-run step 5. Either way the
upgrade keeps `build/` and `static/` writable for your upload, and
`scripts/canary/setup.sh` (remote mode) hands the served folder to
your SSH login when it can — so you should not hit `Permission
denied`.
A freshness alarm for yourself (so you find out before your users
do — catches an expired PGP key, a bad upload, a forgotten
re-upload after a deploy). On the server, `morphit-ops health` also
watches the served canary — red if it's missing or past its date,
amber while it's still valid but low on remaining time (the sign a
weekly refresh has stalled). For an independent check from OUTSIDE
the server, run the verifier from your OWN machine; it checks
the live URL exactly as a user's browser would:
```sh
# daily cron on YOUR machine (not the server):
cd ~/path/to/morphit
if ! npx tsx scripts/canary/verify.ts https://morphit.example.com/canary.txt; then
echo "Morphit canary stale or invalid" \
| mail -s "morphit canary FAILED" you@example.com
fi
```
(The Security page already explains the 14-day rule to users and
links to your `/canary.txt` — nothing to set up there. Note there
is no *automatic* staleness banner: detecting a stopped canary is
the user's / a watchdog's job, done by reading the `Generated:`
date. That manual check IS the canary's whole point.)
### What to do if you are served with a gag order
This is the canary's whole point. **Do not lie.** Stop updating
the canary. Your users switch to other operators (the
federation), and Morphit's marketplace continues without you.
That is by design. If you cannot even tell anyone you have
stopped, just stop re-signing and re-uploading it — the
`Generated:` date stops advancing, and users (or a watchdog)
who read it treat a canary that hasn't updated in 14 days as
silent and switch — the Security page states exactly that rule.
### Privacy considerations
The generator fetches three external resources when you run it
(on your signing machine, never on user devices):
- a Blurt RPC for the chain head (defaults to the built-in
`DEFAULT_BLURT_RPC_ENDPOINTS` rotator with failover — any Blurt
RPC works; pin one with `MORPHIT_CANARY_BLURT_RPC` if you prefer),
- blockstream.info for the Bitcoin chain head (currently
hardcoded; file an issue if you need your own bitcoind),
- an RSS feed for news entropy (default Cointelegraph — choose any
high-frequency public feed you trust).
Users who fetch /canary.txt only hit your own static file, so the
canary never leaks user IPs to third parties.
## 37. Comprehensive server hardening — defense-in-depth checklist
The earlier sections (§5 in `RUN-A-MORPHIT-NODE.md`, §32 BunkerWeb,
§34 UFW + fail2ban, §35 TLS) cover the application-layer defenses.
This section covers everything else — the OS / kernel / SSH /
database / process-isolation / monitoring / physical layers an
attacker will probe once they realize the application itself
isn't trivially exploitable.
**The threat model for this section** is a determined attacker
who has already seen the public source code and exhausted the
obvious application-layer attacks documented in `SECURITY.md`.
What's left is host-level intrusion: SSH brute-force, kernel
exploits, lateral movement from a low-priv compromise, log
tampering, backup theft, evil-maid attacks on a stolen disk.
Each subsection here is a concrete, copy-pasteable defense.
**This section is a *checklist*, not a step-by-step.** Treat
each subsection as independent and skippable — every one of
them improves the security posture, and skipping any one of
them is a tradeoff you should make consciously, not by
default. An operator who applies all of them is well above
the typical-VPS-deployment baseline.
### Before you start — the three highest-stakes gotchas
If you only remember three things from this section, remember
these. Each one is a footgun that costs more than the time
spent reading it.
1. **SSH lockout (§37.1).** Before reloading sshd after the
key-only / no-root config change, open a SECOND ssh session
to the host and confirm key-based login works in it. Only
then close the first session. Every doc says this; people
still get it wrong; the recovery path is console / KVM
access to the VPS.
2. **BunkerWeb trusted-proxy IPs (§32).** The relay only
trusts `X-Forwarded-For` from loopback by default. Behind
BunkerWeb that's wrong in two opposite ways:
- If you do NOT set `MORPHIT_RELAY_TRUSTED_PROXY_IPS`, the
relay sees every user as the same IP (BunkerWeb's). One
abuser exhausts the daily rate limit for everyone.
- If you set it too WIDE (e.g., `0.0.0.0/0`), anyone can
spoof `X-Forwarded-For` and bypass rate limiting entirely.
Set it to the exact CIDR/IP of your BunkerWeb upstream. See
§32 for the Docker-compose bridge case.
3. **Postgres binding (§37.8).** If `postgresql.conf` has
anything other than `localhost` / `127.0.0.1` in
`listen_addresses`, you're exposed to the network. Default
is loopback-only; verify it wasn't changed by Docker or by
a well-meaning previous admin. Test from an external IP:
`psql -h <public-ip> -U morphit_indexer` should time out,
never connect.
### Suggested apply order
Subsections are independent and can be applied in any order,
but a sensible sequence for a fresh deployment is:
§37.1 → §37.2 → §37.3 → §37.4 → §37.5 → §37.6 → §37.7
→ §37.8 → §37.9 → §37.10 → §37.11 → §37.12 → §37.13
→ §37.14 → §37.15 → §37.16 → §37.17 → §34 (UFW + fail2ban)
→ §35 (TLS) → §32 (BunkerWeb) → §38 (squatter defense)
→ §37.18 (verification map)
Test after each. If you're triaging a partially-hardened
existing deployment, start with §37.18 to identify what's
missing and work backwards.
### 37.1 SSH hardening
Default Ubuntu sshd_config is OK but not great. Tighten it.
Edit `/etc/ssh/sshd_config.d/99-morphit-hardening.conf` (a
new file in the `.d` directory wins over the main config):
```
# Refuse password authentication entirely. Keys only.
# DANGER: lock yourself in via a working key BEFORE setting
# this! Test from a SECOND ssh session that key login works,
# THEN apply this and reload sshd.
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
# Refuse root login. Use a sudo-capable operator account.
PermitRootLogin no
# Lower the brute-force surface. Default is 6.
MaxAuthTries 3
MaxSessions 5
# Disconnect idle sessions after 10 minutes.
ClientAliveInterval 300
ClientAliveCountMax 2
# Whitelist by user. Replace 'morphit' with whatever account
# you use to operate the host.
AllowUsers morphit
# (optional) AllowGroups sudoers
# Refuse X11 / agent / TCP forwarding unless you actually use
# them. If you do, comment these out.
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
PermitTunnel no
# Slow-loris defense — don't let half-open SSH connections
# linger.
LoginGraceTime 30s
# Modern crypto only — no RSA-1024 leftovers, no DSA, no MD5
# HMACs. Ubuntu 24's defaults are already mostly fine; this
# is belt-and-suspenders.
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com
# Log every login (success/fail) to syslog, which auditd then
# captures (see §37.6).
LogLevel VERBOSE
```
Apply:
```sh
# CRITICAL: open a SECOND ssh session and confirm key-only
# login works in it BEFORE reloading sshd. If you can't log
# in to the second session, fix sshd_config in the FIRST
# session (which is still alive) before reloading.
sudo sshd -t # syntax check; must print nothing on success
sudo systemctl reload ssh
```
**Optional: change the SSH port.** Moves you off the bot
firehose (port 22 is scanned constantly). Doesn't add real
security against a targeted attacker, but reduces log noise
by 99%+. If you do this, update UFW (`sudo ufw allow 2222/tcp`
or whatever you pick) BEFORE reloading sshd.
### 37.2 Unattended security upgrades
The biggest risk to a long-running Linux box is "operator
forgot to apply the kernel CVE patch from 8 months ago."
Automate the security-only patch stream:
```sh
sudo apt install -y unattended-upgrades apt-listchanges
sudo dpkg-reconfigure -plow unattended-upgrades
# Choose "Yes" to enable automatic updates.
```
Verify the config picks ONLY security upgrades (not feature
upgrades, which can break things mid-night):
```sh
grep -E '^[^/]*"[^"]*";' /etc/apt/apt.conf.d/50unattended-upgrades \
| grep -v Allowed-Origins
```
The default Ubuntu 24 config enables ESM-Apps + ESM-InfraSec +
distro security updates. It does NOT enable feature updates.
That's correct.
To make sure the timer actually runs:
```sh
systemctl status unattended-upgrades.service
systemctl status apt-daily.timer apt-daily-upgrade.timer
# All three should be active/enabled.
```
After a security upgrade lands, services that link the
upgraded library may need restart. Install `needrestart`:
```sh
sudo apt install -y needrestart
# Default mode (interactive) prompts at next login. For a
# headless production box, edit /etc/needrestart/needrestart.conf:
# $nrconf{restart} = 'a'; # auto-restart services
# $nrconf{kernelhints} = 0; # don't print kernel-needs-reboot to login MOTD if you handle that elsewhere
```
Apply changes:
```sh
sudo dpkg-reconfigure needrestart
```
For kernel updates — those need a reboot. Either schedule one
manually after `needrestart` reports a kernel-pending state,
or accept Ubuntu's `Unattended-Upgrade::Automatic-Reboot
"true";` (commented-out by default). Recommended posture:
**leave automatic-reboot OFF**, monitor `/var/run/reboot-required`,
and reboot on a maintenance window — your relay's BLURT-broadcast
in-flight transactions tolerate operator-initiated reboots
better than mid-night surprise ones.
### 37.3 Kernel sysctl hardening
Drop these into `/etc/sysctl.d/99-morphit-hardening.conf`:
```
# ─── Network: spoof / source-routing / redirect defenses ──
# Ignore source-routed packets (a 1990s LAN trick attackers
# still occasionally try).
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
# Ignore ICMP redirects (used in MITM / route-poisoning).
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
# Don't send ICMP redirects (we're not a router).
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
# Reverse-path filter. Drop packets whose source IP wouldn't
# normally route back the way it came (anti-spoof).
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Log martian packets. Spammy but useful during incident response.
net.ipv4.conf.all.log_martians = 1
# Ignore broadcast pings (smurf-attack defense).
net.ipv4.icmp_echo_ignore_broadcasts = 1
# Ignore bogus error responses.
net.ipv4.icmp_ignore_bogus_error_responses = 1
# SYN cookies — defends against SYN-flood DoS.
net.ipv4.tcp_syncookies = 1
# Increase the SYN backlog so a small SYN flood doesn't
# starve legitimate connections.
net.ipv4.tcp_max_syn_backlog = 4096
# ─── Kernel: information disclosure / privilege escalation ──
# Restrict /proc/kcore, /var/log/dmesg etc. to root. Hides
# kernel pointers from local-unprivileged exploits.
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
# Disable suid core dumps — they can leak secrets the suid
# binary handled.
fs.suid_dumpable = 0
# Restrict kernel logs.
kernel.printk = 3 4 1 3
# ptrace defense — prevent processes from attaching to OTHER
# processes' memory unless they're a parent or have CAP_SYS_PTRACE.
kernel.yama.ptrace_scope = 1
# Restrict perf_event so unprivileged users can't profile
# kernel addresses.
kernel.perf_event_paranoid = 3
# Disable BPF for non-root (Ubuntu default is 2 ≈ "root only").
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
# ─── Filesystem ──
# Only allow regular users to follow symlinks they own (or
# the directory's owner). Hardlinks similarly. Defends
# against /tmp-race attacks.
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
# Protect FIFOs and regular files against world-writable-tmp
# overwrite races (5.x kernel feature).
fs.protected_fifos = 2
fs.protected_regular = 2
```
Apply without reboot:
```sh
sudo sysctl --system
# Read-back to confirm the file took effect:
sudo sysctl -a 2>/dev/null | grep -E 'kptr_restrict|tcp_syncookies|rp_filter' | sort -u
```
### 37.4 Filesystem mount hardening
Add these mount options to `/etc/fstab` to reduce what an
attacker can do if they get a writable foothold in /tmp,
/var/tmp, or /dev/shm:
```
# /etc/fstab — additions / modifications
tmpfs /tmp tmpfs defaults,nosuid,nodev,noexec 0 0
tmpfs /dev/shm tmpfs defaults,nosuid,nodev,noexec 0 0
# /var/tmp — bind-mount to /tmp so it shares the same hardening
/tmp /var/tmp none bind 0 0
```
`nosuid` defangs attempt to drop a setuid binary in /tmp and
exploit it. `nodev` blocks creation of device nodes. `noexec`
blocks running binaries from /tmp — most fileless droppers
download to /tmp and chmod+x. ⚠ **Some installers and apt
hooks expect /tmp to be executable**; if `apt upgrade` starts
failing, temporarily remount /tmp without noexec for the
upgrade window:
```sh
sudo mount -o remount,exec /tmp
sudo apt upgrade
sudo mount -o remount /tmp # back to fstab settings
```
Alternative: skip noexec on /tmp specifically and only apply
to /var/tmp + /dev/shm. That's the conservative posture — most
of the attack value is in /dev/shm (which legitimate apps
rarely write to) and /var/tmp (used by long-lived caches), not
/tmp.
Apply:
```sh
sudo mount -a # remount everything per fstab; failure here means a typo
sudo systemctl daemon-reload
# Verify:
mount | grep -E '/tmp|/dev/shm|/var/tmp'
```
### 37.5 Process / capability hardening for the Morphit services
Morphit ships systemd units in `ops/systemd/`. As shipped they
run as **`User=root`**, matching the root-owned `/opt/morphit`
install tree and operator config (the canonical `morphit-ops` and
Ansible deployments both copy these units verbatim, so this is the
default everywhere). Two hardening moves apply. **First, and
highest-value: de-privilege them** — chown `/opt/morphit`'s config
+ data to a dedicated service user and set `User=`/`Group=` in
each unit (the unit's own header comment spells out exactly this).
**Second: add the systemd-level isolation below**, which tightens
the blast radius whether the service runs as root or as a
dedicated user. Edit each service file (or create a drop-in at
`/etc/systemd/system/morphit-indexer.service.d/hardening.conf`,
similar for `morphit-relay.service`):
> **The web frontend has no systemd unit.** The frontend is
> static HTML/CSS/JS built by `npm run build`; nginx serves it
> from `/var/www/morphit-frontend` (root path set in
> `ops/nginx/web.conf`). Hardening for the web tier is an
> nginx-config concern, not a systemd one.
```ini
[Service]
# Filesystem isolation — paths here MUST match what the unit
# actually writes to (data dir, log dir). Ubuntu's path
# conventions:
#
# indexer: /var/lib/morphit (DB-adjacent state)
# relay: /var/lib/morphit-relay (relay state, BLURT cache)
# logs: /var/log/morphit/<service> (only if you log to file
# rather than journald — most
# Morphit services log to
# journald, in which case
# the log path can be omitted)
#
# NOTE: the canonical morphit-ops / Ansible install runs from
# /opt/morphit with DATA in Postgres (a Docker container reached
# over TCP) and logs in journald — so it writes almost nothing to
# the local filesystem. The /var/lib paths above are illustrative
# and apply only if you have deliberately relocated per-service
# state there; set ReadWritePaths to whatever YOUR install writes
# to (test with `ProtectSystem=strict`, then add paths for any
# "read-only file system" errors in the journal).
#
# Each service unit gets ONLY the paths it owns. An indexer
# unit shouldn't list /var/lib/morphit-relay; a relay unit
# shouldn't list /var/lib/morphit. This is part of the
# isolation goal — a compromised relay can't trample the
# indexer's data.
ProtectSystem=strict
ProtectHome=true
# Indexer service:
ReadWritePaths=/var/lib/morphit
# Relay service (use this line INSTEAD on the relay's drop-in):
# ReadWritePaths=/var/lib/morphit-relay
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid
# Networking — Morphit needs IPv4/v6/UNIX, nothing exotic
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
IPAddressDeny=any
# (then explicitly allow what we need; the strictest form
# requires you to allow each remote address individually,
# which is impractical for the indexer's chain RPC fanout.
# Pragmatic: allow everything but log it via auditd. See §37.7.)
IPAddressAllow=any
# Privilege/capability isolation
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
RestrictRealtime=true
# Node's V8 JIT needs writable-AND-executable memory pages, so
# MemoryDenyWriteExecute=true crashes the runtime at startup.
# Leave this off for Morphit's services. If you ever ship a
# version that runs through `--jitless` (much slower), you can
# turn this on.
MemoryDenyWriteExecute=false
SystemCallArchitectures=native
# Node needs more syscalls than @system-service alone provides
# (epoll variants, getrandom, eventfd, sched_*, mmap with PROT_EXEC).
# @system-service + @network-io + the listed allowlist works for
# typical Node 22.x. If your service still fails to start,
# inspect `journalctl -u <service> -n 100` for "blocked syscall"
# messages and add the syscall to the filter explicitly.
SystemCallFilter=@system-service @network-io
SystemCallFilter=~@privileged @resources @debug @mount @reboot @swap @raw-io @cpu-emulation @obsolete
# Resource limits (defense against fork bombs / memory leaks)
LimitNOFILE=16384
LimitNPROC=512
TasksMax=1024
```
Apply:
```sh
sudo systemctl daemon-reload
sudo systemctl restart morphit-indexer.service
sudo systemctl restart morphit-relay.service
# Confirm tightened settings took effect:
sudo systemctl show morphit-indexer.service | grep -E 'ProtectSystem|NoNewPrivileges|SystemCallFilter' | head
```
If the relay refuses to start after applying these (signed
broadcasts use Node native crypto which may need more
syscalls than the filter covers), relax incrementally:
1. Check `journalctl -u morphit-indexer.service -n 100` for
`seccomp` denial entries. They name the syscall that was
blocked — `audit: type=1326 ... syscall=257 ...` — and you
can add that syscall name to a permissive `SystemCallFilter`
line.
2. Try `SystemCallFilter=@common` instead of `@system-service @network-io`.
`@common` is broader and will catch more legitimate Node usage.
3. As a last resort, comment out the SystemCallFilter lines
entirely and keep the other ProtectSystem / NoNewPrivileges /
ReadWritePaths defenses — those alone still block 80% of
the post-compromise attack surface.
The tradeoff matrix is: tighter config = harder to exploit if
the service is compromised, but more fragile to legitimate
runtime needs. Accept the loosest setting that actually works.
### 37.6 auditd — log everything that looks suspicious
`auditd` is the kernel-level audit framework. It's noisy by
default but indispensable when investigating "did anyone log
in via SSH between Tuesday and now."
```sh
sudo apt install -y auditd audispd-plugins
sudo systemctl enable --now auditd
```
Drop a Morphit-specific ruleset at `/etc/audit/rules.d/morphit.rules`:
```
# Watch successful + failed SSH logins
-w /var/log/auth.log -p wa -k auth-events
# Watch sudoers and PAM config — any change here is interesting
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d -p wa -k sudoers
-w /etc/pam.d -p wa -k pam
# Watch the morphit user's authorized_keys — an attacker who
# wants persistence WILL drop a key here.
-w /home/morphit/.ssh/authorized_keys -p wa -k ssh-keys
# Watch crontabs — second favorite persistence mechanism.
-w /etc/cron.d -p wa -k cron
-w /etc/cron.daily -p wa -k cron
-w /etc/cron.hourly -p wa -k cron
-w /etc/crontab -p wa -k cron
-w /var/spool/cron -p wa -k cron
# Watch systemd unit dirs — third favorite.
-w /etc/systemd/system -p wa -k systemd
-w /lib/systemd/system -p wa -k systemd
# Watch Morphit's own files — config drift signals trouble.
-w /opt/morphit -p wa -k morphit-files
# Privileged escalation attempts
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=4294967295 -k privesc
# Rule editing protection — make the ruleset itself immutable
# until reboot. Comment out during testing.
# -e 2
```
Apply:
```sh
sudo augenrules --load
sudo systemctl restart auditd
# Read recent events:
sudo aureport -i --summary
sudo ausearch -k ssh-keys -ts today
```
Configure auditd's own log rotation in `/etc/audit/auditd.conf`:
```
max_log_file = 50 # MB
num_logs = 8
max_log_file_action = ROTATE
disk_full_action = SYSLOG # don't halt the system if /var fills
disk_error_action = SYSLOG
```
### 37.7 AppArmor profiles for Morphit services
Ubuntu ships AppArmor enabled by default; verify:
```sh
sudo aa-status
# Should show "apparmor module is loaded" and a list of confined
# profiles. If it's not loaded, you're on a stripped image
# (or AppArmor was disabled deliberately).
```
Custom profiles for `morphit-indexer` and `morphit-relay` are
**aspirational** (writing a tight AppArmor profile that doesn't
break Node + libsodium + every npm transitive is a research
project). The pragmatic posture:
1. Confine `nginx` (already covered by Ubuntu's stock profile).
2. Confine `postgresql` (also stock).
3. Leave the Node services in `unconfined` mode — the systemd
hardening in §37.5 covers the same isolation surface for
those.
If you want to explore writing a Morphit-specific profile,
start with `aa-genprof` while running the relay in a non-prod
environment and exercise the full surface. Expect 2-4 hours
of profile tuning before it's stable.
### 37.8 Postgres hardening beyond `listen_addresses`
§34 covers `listen_addresses = 'localhost'`. Three more
defenses to apply:
**a. SCRAM-SHA-256 password hashing.** Ubuntu 24's default is
already SCRAM-SHA-256; older versions used MD5. Verify:
```sh
sudo -u postgres psql -t -c "SHOW password_encryption;"
# Expected: scram-sha-256
# DANGER: md5
```
If MD5, switch:
```sh
sudo -u postgres psql -c "ALTER SYSTEM SET password_encryption = 'scram-sha-256';"
sudo systemctl reload postgresql
# Now CHANGE every password (just-flipping the setting doesn't
# rehash existing passwords; they stay MD5 until reset):
sudo -u postgres psql -c "ALTER USER morphit_indexer WITH PASSWORD '<new password>';"
```
**b. pg_hba.conf — peer auth for local, scram for everything else.**
Edit `/etc/postgresql/*/main/pg_hba.conf`:
```
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
local all all peer
host morphit_indexer morphit_indexer 127.0.0.1/32 scram-sha-256
host morphit_indexer morphit_indexer ::1/128 scram-sha-256
# DENY EVERYTHING ELSE (implicit; anything not matched above rejects)
```
`peer` for local sockets means Postgres trusts the OS-level UID
of the connecting process, no password needed — that's how the
backup script and DB shell login work. `scram-sha-256` for the
TCP loopback is what Morphit's indexer + relay use, and the
password is in the `.env` file (which is `0640 root:morphit` per
§37.10).
Apply:
```sh
sudo systemctl reload postgresql
# Test that the morphit_indexer user from localhost still works:
psql -h 127.0.0.1 -U morphit_indexer -d morphit_indexer -c 'SELECT 1;'
# Test that postgres user via socket still works:
sudo -u postgres psql -c 'SELECT 1;'
```
**c. Database-level CONNECT permission.** Drop ambient
CONNECT for the morphit_indexer role on databases it doesn't need:
```sh
sudo -u postgres psql <<'PSQL'
REVOKE ALL ON DATABASE postgres FROM morphit_indexer;
REVOKE ALL ON DATABASE template0 FROM morphit_indexer;
REVOKE ALL ON DATABASE template1 FROM morphit_indexer;
GRANT CONNECT ON DATABASE morphit_indexer TO morphit_indexer;
PSQL
```
**d. Statement-level audit (optional).** `pg_audit` is an
extension that logs every DDL + DML — useful for detecting
tampering, but adds 5-15% overhead. Recommended for a high-
value canonical operator host, optional otherwise. Install
with `apt install postgresql-17-pgaudit` (match the major
version installed) and configure via
`shared_preload_libraries = 'pgaudit'` in `postgresql.conf`
plus `pgaudit.log = 'write,ddl'` in the morphit DB's
configuration. Restart Postgres to take effect.
**e. `statement_timeout` — defense-in-depth against runaway
queries.** Morphit's pg pool
(`apps/indexer/src/db/pool.ts`) sets `connectionTimeoutMillis:
5000` and `idleTimeoutMillis: 30000`, but deliberately does
**not** set a pool-level `statement_timeout` — doing so at the
client would force a single value across both the indexer
worker (which writes blocks one-at-a-time and stays sub-second
in steady state) and ad-hoc psql sessions an operator might
use for maintenance. The right place to set this defense is
at the Postgres server, per-database, where the operator
owns the value:
```sh
# Set a 30-second per-statement ceiling for the indexer DB.
sudo -u postgres psql -d morphit_indexer -c \
"ALTER DATABASE morphit_indexer SET statement_timeout = '30s';"
```
This applies to all **new** connections from this point on.
Existing pool connections keep the old value until they
recycle, so reload the services to pick it up promptly:
```sh
sudo systemctl restart morphit-indexer morphit-relay
```
**Choice of value.** `30s` is comfortable for Morphit's normal
HTTP API + per-block indexer transactions (steady-state
queries run in sub-second; 30s is two orders of magnitude of
headroom). Bump to `60s` if you're running unusually large
backfills or your instance has accumulated tables beyond
typical scale. `0` (unlimited) is Postgres's unsafe default —
don't leave it there.
**Initial backfill on a fresh instance.** The poller drains
the chain block-by-block with one transaction per block. No
single statement is ever a deep replay; each block's write
stays well under the timeout even when catching up from
genesis on a brand-new instance. No special handling needed.
**Posting-key backfill (cp404).** On every indexer boot, a
one-shot idempotent backfill populates the `accounts.posting_pubkey`
column (added in schema v36) — the durable BLT posting key shown on
the order cards' identity line and used by the chat PDF export. It
runs `ALTER TABLE accounts ADD COLUMN IF NOT EXISTS posting_pubkey`
(so the column lands on an already-running instance at the next
`morphit-ops upgrade` without a tracked migration), then fills any
`NULL` rows in creation-block order (batched, capped per boot) by
reading each account's current `posting.key_auths[0][0]` from the
chain. It is fire-and-forget and NEVER blocks startup or ingestion;
a failure only logs a warning. Nothing to configure. Expect one of:
- `posting_key_backfill_done` (INFO) — payload `{ensuredColumn,
scanned, updated, remaining}`. `remaining > 0` just means the cap
was hit this boot; the rest fill on subsequent boots. Once
`updated` and `remaining` are both 0, every known account has its
key and the line stops appearing.
- `posting_key_backfill_failed` (WARN) — a chain-read or DB hiccup;
harmless, retried next boot. The order cards simply omit the key
line for un-backfilled accounts until then.
**Ad-hoc long queries.** If you ever need to run a one-off
long query (analytics from psql, a custom report), override
per-session without changing the database default:
```sql
SET statement_timeout = 0;
-- long query here
RESET statement_timeout;
```
**Verify it took effect:**
```sh
sudo -u postgres psql -d morphit_indexer -c \
"SHOW statement_timeout;"
# Expected: 30s
```
You can also verify per-role from inside a Morphit-side psql
session by setting `MORPHIT_INDEXER_DATABASE_URL` and running
`psql "$MORPHIT_INDEXER_DATABASE_URL" -c 'SHOW
statement_timeout;'` — that proves the indexer user picks up
the database-level default through the connection string.
### 37.9 Filesystem integrity monitoring with AIDE
AIDE (Advanced Intrusion Detection Environment) baselines every
file's hash and reports changes. Install once, run nightly,
review the diff in the morning:
```sh
sudo apt install -y aide
# Generate the initial baseline. ⚠ Do this AFTER the system
# is fully configured — a baseline of a half-installed system
# won't tell you anything useful.
sudo aideinit
sudo cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db
```
Configure to ignore noisy paths (logs, /var/cache, /tmp). Edit
`/etc/aide/aide.conf` to taste — Ubuntu's default config is
reasonable but mentions the package's own paths heavily; trim
those before baselining.
Schedule nightly check:
```cron
# /etc/cron.d/aide-morphit
30 3 * * * root /usr/bin/aide --check 2>&1 | mail -s "AIDE on $(hostname)" your-pager@example.com
```
Three things to know:
1. AIDE will alert on every legitimate change too (apt
upgrades, log rotation, `/var/lib/morphit` writes). Tune
the config to ignore those paths or accept the noise.
2. The AIDE database itself MUST be protected — an attacker
who compromises root can update the baseline to hide their
tracks. Mitigations: store the baseline on read-only media
(rare) or, more practically, rsync the baseline off-host
nightly so an attacker can't quietly rewrite it.
3. AIDE does not replace antivirus. It's a tripwire, not a
scanner.
For a lighter-weight alternative, `debsums` checks the integrity
of installed Debian packages against the package manager's
manifests:
```sh
sudo apt install -y debsums
sudo debsums -c # report changed package files
```
Less comprehensive than AIDE but zero-config.
### 37.10 Secrets file hygiene
The Morphit services read `MORPHIT_RELAY_ACTIVE_KEY_FILE`,
Postgres credentials, etc., from environment files (typically
`/etc/morphit/relay.env`, `/etc/morphit/indexer.env`). These
files MUST be `0640`, owned `root:morphit` — root owns them, the
`morphit` service group reads them (what the canonical `morphit-ops` /
Ansible install lays down, and what §37.19 verifies):
```sh
sudo chown root:morphit /etc/morphit/indexer.env
sudo chmod 0640 /etc/morphit/indexer.env
sudo chown root:morphit /etc/morphit/relay.env
sudo chmod 0640 /etc/morphit/relay.env
# Verify:
ls -l /etc/morphit/*.env
# Expected: -rw-r----- 1 root morphit ... (octal 640)
```
The shipped systemd unit sources this file from its `ExecStart`
wrapper (`set -a; . /etc/morphit/indexer.env; set +a` — a shell
wrapper is used deliberately so both `KEY=value` and `export
KEY=value` forms load), which respects the 0640 perm. (If you
switch to an `EnvironmentFile=/etc/morphit/indexer.env` directive
instead, systemd reads it as root before dropping to the service
user — either way the 0640 file is only ever read by a privileged
reader.)
**Don't:**
- Store secrets in `/opt/morphit/.env` (world-readable in a
default git checkout)
- Echo secrets to logs (Morphit's logger redacts context-object
keys whose name matches a secret-suffix pattern — `*_KEY*`,
`*_PASSWORD`, `*_PASSPHRASE`, `*_SECRET`, `*_TOKEN`, `*_WIF`,
`*_MNEMONIC`, plus the camelCase variants `apiKey`,
`activeKey`, `userPassword`, etc. Public identifiers like
`publicKey` / `VAPID_PUBLIC_KEY` are explicitly exempt. See
`isSecretContextKey` / `redactSecrets` in
`apps/indexer/src/log/index.ts` for the canonical list and
unit tests in `apps/indexer/test/log.test.ts` for coverage.
If you add a new secret-shaped env var with a non-matching
name, either rename it to fit the pattern or extend the
allow-list)
- Commit `.env` files to your operator-config repo (use
`.env.example` with placeholder values, .gitignore the real
one)
- Pass secrets via command-line args (`/proc/<pid>/cmdline`
is world-readable)
**Optional but recommended:** encrypted-at-rest secrets via
`age` or `sops`:
```sh
# Initial setup
sudo apt install -y age
age-keygen -o /root/.config/age/morphit-secrets.key
chmod 600 /root/.config/age/morphit-secrets.key
# Encrypt the env file
age -r "$(cat /root/.config/age/morphit-secrets.key | grep '#' | cut -d ' ' -f2)" \
-o /etc/morphit/indexer.env.age \
< /etc/morphit/indexer.env
# Wrap the systemd unit so it decrypts at boot:
ExecStartPre=/usr/bin/age -d -i /root/.config/age/morphit-secrets.key -o /run/morphit/indexer.env /etc/morphit/indexer.env.age
EnvironmentFile=/run/morphit/indexer.env
ExecStopPost=/bin/rm -f /run/morphit/indexer.env
```
This means the disk-image of a stolen drive doesn't reveal
secrets; only the running system can decrypt them. An attacker
who has root on the live system can still read them, but disk
theft + offline forensics is defeated.
#### 37.10.1 The relay's active key — your single highest-value secret
Cross-references the **2026-05-07 deep-deep audit of active/owner
key handling** (`SECURITY.md` §1b). The audit was user-side
(browser code), but this subsection captures the operator-side
implications a sysadmin needs to verify.
The relay holds **one long-lived hot active key** on disk: the
WIF (or encrypted envelope) at `MORPHIT_RELAY_ACTIVE_KEY_FILE`.
Inside the running process it's a long-lived `string` field on
the config object — `relayActiveKeyWif` — used to sign every
account-create, every welcome-bonus transfer, every loyalty-
milestone BP power-up. This key is the operator's single
highest-value secret on the system. If an attacker reads it,
they can drain the operator's BLURT balance and reassign
delegated BP.
**What the user-side audit gives the operator:** nothing direct.
The user's keys never touch your server. Compromised user
keys are user problems, not operator problems.
**What you (the operator) must verify:**
1. **`MORPHIT_RELAY_ACTIVE_KEY_FILE` permissions.** Mode `0400`,
owned by the relay's systemd user. The relay's config
loader enforces this at boot — it refuses to start if
the file is group- or world-readable. Verify with:
```sh
ls -l "$(grep '^MORPHIT_RELAY_ACTIVE_KEY_FILE' /etc/morphit/relay.env | cut -d= -f2)"
# Expected: -r-------- 1 morphit-relay morphit-relay ...
```
2. **Encrypted-on-disk active key (recommended).** Per ADR-0010
§4, the active key file may be a passphrase-encrypted
envelope rather than a bare WIF. See `§3` of this doc for
the migration script (`apps/relay/scripts/encrypt-active-key.ts`). An
encrypted envelope means a stolen disk image yields nothing
without the passphrase, which is entered interactively at
first boot and held in process memory thereafter.
3. **Systemd hardening covers process memory.** §37.5 is
already in effect for `morphit-relay.service` if you
followed §37 in order. The relevant directives that
protect process memory of the running relay are:
- (`MemoryDenyWriteExecute` stays **off** — the relay runs on
Node/V8 via `tsx`, whose JIT needs writable+executable pages,
exactly as §37.5 explains; the shipped `morphit-relay.service`
ships `MemoryDenyWriteExecute=no`)
- `RestrictNamespaces=yes`, `LockPersonality=yes`,
`NoNewPrivileges=yes`
- `ProtectSystem=strict`, `ProtectHome=yes`,
`PrivateTmp=yes`
- `CapabilityBoundingSet=` (empty — no caps)
- `SystemCallFilter=@system-service @network-io`
These don't prevent in-process memory reads (you'd need a
kernel-level seccomp filter for that — out of scope for a
user-space service), but they prevent the relay's
process from being escalated, ptrace'd by a non-root
peer, or used as a pivot to attack other services.
4. **AppArmor profile (optional but high-value).** §37.7
defines an AppArmor profile for `morphit-relay` that
denies all filesystem access except the key file, the
env file, and the IPC socket. An attacker who finds an
RCE in the relay's HTTP layer can't read `/etc/passwd`,
`/proc/*/maps`, or the indexer's database file even if
they get arbitrary code execution.
5. **No exfil channel.** §37.13 (UFW egress allowlist)
limits outbound connections to your Blurt RPC pool.
Even with code execution inside the relay, an attacker
can't `curl -d "$ACTIVE_WIF" attacker.example/`.
6. **Backup hygiene.** §37.12 covers age-encrypted backups.
**Do NOT include the bare WIF in any backup that isn't
itself encrypted.** If your `/etc/morphit/keys/` is in
a snapshot, that snapshot needs to be encrypted with the
same care.
7. **Owner key NEVER on the server.** Per §0 (Initial
account setup), the **owner key for `@morphit-relay`
stays on paper, in a safe, off any networked machine**.
Without the owner key offline, you cannot rotate the
active key after a compromise. Without rotation, a
stolen active key is a stolen-forever active key. This
is the single most important sysadmin discipline for
running a relay.
**What the audit tells you when something goes wrong:**
If a structured-log line `envelope_unlock_via_credential_file`
(or `envelope_unlock_via_env_plaintext`) is emitted at boot but
the relay subsequently fails to broadcast a transfer with an
"invalid signature" error, the in-memory active key may have
been corrupted by an attacker who has root. Stop the relay
immediately (§7 — Suspected relay compromise) and rotate.
If you find unexplained `transfer` ops on `@morphit-relay`'s
chain history that you didn't initiate (welcome-bonus drainer
ops are normal; ops to unknown accounts are not), the active
key is presumed compromised. §7 + §8 documents the response.
**What this subsection does NOT cover:**
- Browser-side user-key handling. See `SECURITY.md` §1b for
that audit.
- Multi-instance setups where `@morphit-relay` and
`@morphit-relay-2` share an owner. See §11.5 of
`RUN-A-MORPHIT-NODE.md` for sub-account architecture.
- Hardware-key-backed operator keys. Out of scope today;
Yubico's PKCS#11 path against secp256k1 isn't supported by
any Blurt signing library, so the operator's active key
is necessarily a software key. This is a Blurt-protocol
limitation, not a Morphit one.
### 37.11 Disk encryption (LUKS)
If the host could be physically stolen — VPS provider with
shared disks, on-prem hardware, anything you don't 100% trust
the supply chain on — encrypt the disk. This is a setup-time
decision, not a post-hoc one (well, you CAN encrypt in-place
with cryptsetup-reencrypt, but it's a 12-hour ordeal you'd
rather avoid).
For VPS providers:
- **Vultr / Hetzner / DigitalOcean** — pick "encrypted disk" at
provisioning time if offered. If not, install Ubuntu via
the provider's recovery/iso-boot path with full-disk
encryption enabled in the installer.
- **AWS EC2** — enable EBS encryption at volume creation time
(default-encrypt account-wide setting recommended).
- **Bare metal** — Ubuntu Server installer's "Use entire
disk and set up encrypted LVM" option; pick a strong
passphrase and store it offline.
The tradeoff: encrypted disk requires a passphrase at boot,
which means automated reboots no longer work without either
(a) a TPM that releases the passphrase to the kernel during
boot or (b) `dropbear-initramfs` so you can SSH in pre-boot
and unlock manually.
For a Morphit relay that processes signed BLURT broadcasts
and holds significant runway, the boot-passphrase friction is
worth it. For a low-stakes secondary instance, default-
unencrypted-VPS is acceptable.
### 37.12 Backup encryption
`docs/RUN-A-MORPHIT-NODE.md §10` documents daily DB backups
via `ops/backup/morphit-backup.sh`. The script has built-in
support for two protections — both off by default, both
enabled by editing `/etc/morphit/backup.env` (no script
modification needed):
1. **Per-backup age encryption.** Set `AGE_RECIPIENT=age1...`
to the operator's age public key. Every backup is then
encrypted with `age -r "$AGE_RECIPIENT"` before being
written to disk; the resulting filename ends in
`.sql.gz.age` instead of `.sql.gz`.
Generate the keypair OFF this host (laptop, vault host,
hardware token):
```sh
age-keygen -o ~/.age/morphit-backup.key
```
The first line of `morphit-backup.key` is the public key
(`age1...`); copy it into `/etc/morphit/backup.env`. The
matching PRIVATE key MUST stay off the morphit host — an
attacker who roots the box should not be able to decrypt
your offsite backups.
2. **Off-host push.** Set
`REMOTE_DESTINATION=user@backup-host:/morphit/` (any
rsync-compatible target) and optionally
`SSH_KEY=/etc/morphit/backup-ssh-key`. Every backup
is then rsync'd off this host immediately after the
local write. rsync errors are warned but non-fatal —
the local copy remains the source-of-truth.
**Placeholder-value guardrail (cp131):** if either
`AGE_RECIPIENT` or `REMOTE_DESTINATION` still contains a
placeholder marker (`REPLACE`, `XXXXX`, `example.com`,
`CHANGE_ME`), the script SKIPS that feature and logs a
journald warning rather than silently shipping plaintext
to a bogus host or producing unencrypted backups operators
believed were encrypted. An operator following the
Ansible defaults (which leave both empty) gets local-only
plaintext backups — no silent leak.
To verify a backup is genuinely encrypted, try to read it
WITHOUT the age private key:
```sh
zcat /home/morphit/backups/morphit-20260523-040000.sql.gz.age 2>&1 | head
# Expected: gzip: stdin: not in gzip format
# (because the bytes are age-encrypted, not gzipped)
age --decrypt -i ~/.age/morphit-backup.key \
/home/morphit/backups/morphit-20260523-040000.sql.gz.age \
| zcat | head
# Expected: real SQL dump content.
```
### 37.13 Outbound network policy
Morphit's services initiate outbound connections to:
- Blurt RPC nodes (default `https://rpc.blurt.blog:443`,
others — see §22)
- Bitcoin block explorers (in the canary script + the BTC
explorer-fee verifier)
- Monero block explorers (XMR explorer-fee verifier)
- The relay's chain-broadcast endpoint (Blurt p2p / RPC)
- An RSS feed for canary news entropy
- Optionally, Anthropic / Claude API for any operator-specific
alerting tooling you've built (out of scope for the canonical
install)
UFW's default-allow-outbound is fine for normal operation, but
"egress-deny by default with explicit allowlist" is the
strictest posture. This is a high-friction defense — most
operators skip it because it breaks `apt upgrade`, npm installs,
and ad-hoc debugging — but it dramatically reduces the value
of a low-priv compromise (an attacker who got code execution as
the `morphit` user can't open a reverse shell to their C2 if
outbound is restricted).
If you want to apply it:
```sh
# Deny all outbound by default
sudo ufw default deny outgoing
# Allow DNS (most providers' resolver)
sudo ufw allow out 53/udp
sudo ufw allow out 53/tcp
# Allow NTP
sudo ufw allow out 123/udp
# Allow HTTPS (for Blurt RPC, BTC/XMR explorers, RSS feeds, npm)
sudo ufw allow out 443/tcp
# Allow Blurt p2p (default 9999, varies)
# sudo ufw allow out 9999/tcp
# Allow SSH out (so you can clone git repos via ssh:// and run
# operator-side scripts that call out)
sudo ufw allow out 22/tcp
# Allow SMTP if you use a relay for alerts (587 = STARTTLS)
sudo ufw allow out 587/tcp
# Apply
sudo ufw reload
```
Be ready to temporarily relax this for `apt upgrade` etc.
that hit non-443 mirrors:
```sh
sudo ufw default allow outgoing # temporary
sudo apt upgrade
sudo ufw default deny outgoing
sudo ufw reload
```
**Recommended posture for Morphit:** apply the egress allowlist
on the relay host (high-value target, narrow scope of legitimate
outbound) but skip it on the indexer host (broad legitimate
outbound to RPC nodes, explorers, etc., where allowlist
maintenance becomes a chore).
### 37.14 Operator alerting — outbound email
Most of the alerting hooks in §16 (operator-account balance),
§37.6 (auditd), §37.9 (AIDE), and §31 (backup) reference
sending mail. Configure outbound SMTP once:
```sh
# Lightweight: msmtp, a single-user SMTP relay (no full
# postfix install needed).
sudo apt install -y msmtp msmtp-mta mailutils
# Edit /etc/msmtprc:
sudo tee /etc/msmtprc > /dev/null <<'MSMTP'
defaults
auth on
tls on
tls_starttls on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile /var/log/msmtp.log
account morphit-alerts
host smtp.gmail.com
port 587
from morphit-alerts@<your-domain>
user morphit-alerts@<your-domain>
password <app-specific-password>
account default : morphit-alerts
MSMTP
# Permissions: msmtp refuses to run if /etc/msmtprc is readable
# by non-root.
sudo chmod 0600 /etc/msmtprc
# Test:
echo "test from $(hostname)" | mail -s "morphit alert test" your-pager@example.com
```
Caveats:
- Don't put the SMTP password in version control.
- Use an "app-specific" or service-account password, not your
primary email account password (so a host compromise doesn't
give the attacker your email).
- Consider a dedicated alerting domain — `alerts@morphit.example`
with a sieve rule to highlight messages from each of your
operator hosts.
Once configured, every other section's `mail -s ...` invocation
just works.
### 37.15 Rootkit / malware scanners (optional)
`rkhunter` and `chkrootkit` scan for known signatures of
common rootkits. False-positive heavy on a non-stock system,
but cheap to run weekly:
```sh
sudo apt install -y rkhunter chkrootkit
sudo rkhunter --update
sudo rkhunter --propupd # baseline current state
# Schedule:
echo '30 4 * * * root /usr/bin/rkhunter --check --skip-keypress 2>&1 | mail -s "rkhunter on $(hostname)" your-pager@example.com' \
| sudo tee /etc/cron.d/rkhunter-morphit
```
For a paranoid operator: `clamav` for full antivirus. Heavy
(50-200MB RAM resident) but catches things rkhunter doesn't.
Optional — most Linux compromises are command-line tools an AV
won't recognize.
### 37.16 GRUB / boot hardening
If physical access is in your threat model (on-prem hardware,
colo, anything with a console you don't control), a GRUB
password prevents boot-time `init=/bin/bash` recovery:
```sh
sudo grub-mkpasswd-pbkdf2
# Enter and confirm a strong passphrase. Copy the
# `grub.pbkdf2.sha512.10000.<long hash>` output line.
# Edit /etc/grub.d/40_custom:
sudo tee -a /etc/grub.d/40_custom > /dev/null <<'GRUB'
set superusers="morphit-boot"
password_pbkdf2 morphit-boot grub.pbkdf2.sha512.10000.<paste your hash here>
GRUB
# Edit /etc/grub.d/10_linux to allow the default boot entry to
# run UNLOCKED (otherwise every reboot prompts for the
# passphrase, which is fine if that's what you want):
sudo sed -i 's/CLASS="--class gnu-linux/CLASS="--unrestricted --class gnu-linux/' /etc/grub.d/10_linux
sudo update-grub
```
Result: normal boot proceeds without prompt; editing the boot
entry (the only path to passing `init=/bin/bash`) requires the
passphrase.
Skip this on a remote VPS — you have no console anyway, and
the boot passphrase just becomes operational friction.
### 37.17 Operator account password discipline
Even with key-only SSH, the OS-level operator account has a
password (used for sudo). Strengthen:
```sh
# /etc/security/pwquality.conf — minimum quality bar
sudo tee -a /etc/security/pwquality.conf > /dev/null <<'PWQ'
minlen = 14
minclass = 3
maxrepeat = 3
maxsequence = 3
gecoscheck = 1
dictcheck = 1
PWQ
# /etc/login.defs — password aging
sudo sed -i 's/^PASS_MAX_DAYS\t.*/PASS_MAX_DAYS\t365/' /etc/login.defs
sudo sed -i 's/^PASS_MIN_DAYS\t.*/PASS_MIN_DAYS\t1/' /etc/login.defs
sudo sed -i 's/^PASS_WARN_AGE\t.*/PASS_WARN_AGE\t14/' /etc/login.defs
```
PAM picks up pwquality.conf for `passwd` calls. Existing
passwords aren't immediately rotated — set a reminder for
yourself to rotate within 30 days of applying this.
### 37.18 Final checklist — what an attacker would try, and your defense
| Attack | Defense | Section |
|---|---|---|
| SSH brute-force | Key-only + fail2ban + AllowUsers | 37.1, 34 |
| Stolen SSH key from operator laptop | MaxAuthTries + key passphrase + revocation drill | 37.1, 7 |
| Kernel privilege escalation via known CVE | unattended-upgrades + needrestart | 37.2 |
| Local kernel exploit needing kernel info disclosure | dmesg/kptr restrict + perf_event_paranoid | 37.3 |
| /tmp dropper + chmod+x exploit | tmp/dev/shm noexec | 37.4 |
| Service compromise → host takeover | systemd ProtectSystem/CapabilityBoundingSet | 37.5 |
| Persistence via cron / authorized_keys / systemd | auditd watches on each | 37.6 |
| AppArmor escape | (limited applicability — we run unconfined Node) | 37.7 |
| Postgres password sniff via MD5 | scram-sha-256 | 37.8 |
| File tampering after compromise | AIDE nightly diff + offsite baseline | 37.9 |
| Secret leak from world-readable .env | 0640 root:morphit + age encryption | 37.10 |
| Disk theft | LUKS full-disk encryption | 37.11 |
| Backup theft → offline crack | age-encrypted backups | 37.12 |
| Reverse-shell exfiltration | UFW egress allowlist (relay host only) | 37.13 |
| Silent-failure of any of the above | msmtp + cron-driven alerting | 37.14 |
| Known rootkit signature | rkhunter weekly | 37.15 |
| Console-level recovery / init=/bin/bash | GRUB password | 37.16 |
| Sudo password brute | pwquality + login.defs | 37.17 |
| Name-squatting brand grab | Layer 7 high-value name policy | 18, 38 |
| Name-squatting enumeration | Layer 8 sequential pattern detector | 18, 38 |
| Bot signup flood | Altcha PoW + per-IP spacing + BunkerWeb rate limits | 18, 32 |
| Forged X-Forwarded-For for rate-limit bypass | trusted-proxy IP allowlist | 32 |
| Mass account creation drain | global daily ceiling + LOW_BALANCE alert | 18 |
If your threat model warrants it, also consider:
- A dedicated **bastion host** that's the only thing allowed
to SSH into the production host (UFW: deny SSH from anywhere
except the bastion's IP).
- A **separate read-replica Postgres** for query-heavy
analytics so the production database serves only the
indexer's writes.
- **Hardware security keys** (YubiKey) for SSH instead of
on-disk private keys.
- **Audit-log shipping** to a separate "log host" so an
attacker who roots the production box can't tamper with
the evidence trail.
- **2-of-3 multisig BLURT operator accounts** — operationally
more friction but a single key compromise no longer drains
the relay (currently aspirational; tracked in
`docs/REVISIT-LIST.md`).
**These are NOT requirements.** §37.1 through §37.17 alone put
you well above 95% of self-hosted Linux servers on the public
internet. Apply them in order, test each, and stop when you
hit your operational risk tolerance.
### 37.19 Verification checklist — prove each defense actually fires
A hardening pass that wasn't verified isn't a hardening pass.
Ansible reporting success, a service starting cleanly, an
sshd reload not throwing an error — none of these prove the
defense itself works. Each check below is a concrete command
that fails fast if the corresponding subsection didn't take
effect.
Run from your laptop unless noted; "host" means the morphit
server.
**SSH posture (§37.1):**
```sh
# Root login disabled
ssh root@host # should fail: "Permission denied (publickey)"
# Password auth disabled
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no \
youruser@host # should fail: "Permission denied"
# Verify the actual sshd_config the daemon is running with
ssh youruser@host sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication)'
# Expect: permitrootlogin no
# passwordauthentication no
# kbdinteractiveauthentication no
```
**Network surface (§34, §37.13):**
```sh
# Only expected ports should be open externally
nmap -Pn -p 1-65535 host # expect: 22, 80, 443 only
# Postgres NOT reachable externally (§37.8)
psql -h host -U morphit_indexer -d morphit_indexer
# expect: connection timeout, NOT a password prompt
```
**Trusted-proxy CIDR (§32) — the asymmetric footgun:**
From an IP NOT in `MORPHIT_RELAY_TRUSTED_PROXY_IPS`, send a
spoofed X-Forwarded-For and verify the relay does NOT trust it
for rate-limiting purposes. Easiest way: hit a rate-limited
endpoint from your real IP, then hit it again with a spoofed
XFF claiming a different IP; the second request should be
rate-limited too (proving the relay is reading the socket
peer, not the XFF):
```sh
# Replace the URL with your relay's actual rate-limited endpoint
for i in 1 2 3 4 5 6 7 8 9 10; do
curl -sI -H "X-Forwarded-For: 198.51.100.$i" \
https://yourinstance.example/relay/v1/account/availability/test
done | grep -E 'HTTP|x-ratelimit'
# Expect: 429 (or rate-limit header decrementing) after a few
# requests, NOT 200 for all 10 with a fresh counter per XFF.
```
If every request returns 200 with a fresh rate-limit budget,
your trusted-proxy CIDR is too wide and any user can forge XFF
to bypass rate limiting.
**Secrets file hygiene (§37.10):**
```sh
ssh host 'ls -l /etc/morphit/'
# Expect: env files 0640, owned by root:morphit
# keystore 0400, owned by the relay's service user
# directory itself 0750 root:morphit
```
**Service state (§37.6, §37.9, §37.14, §34):**
```sh
ssh host 'sudo systemctl is-active auditd fail2ban morphit-relay morphit-indexer'
# Expect: active × 4
ssh host 'sudo systemctl list-timers | grep -E "morphit-backup|certbot"'
# Expect: both timers scheduled, next run within the configured window
ssh host 'sudo aide --check' | head -5
# Expect: "AIDE found NO differences" or matching the count from
# the initial baseline. Mismatch = something changed since baseline.
ssh host 'sudo ufw status'
# Expect: Status: active, with the expected ALLOW rules
ssh host 'sudo fail2ban-client status sshd'
# Expect: Currently failed: <small number>; ban list visible
```
**Squatter defense (§38.7) — the env vars are actually loaded:**
```sh
ssh host 'sudo systemctl show morphit-relay -p Environment | tr " " "\n" | grep MORPHIT_RELAY_'
# OR if /etc/morphit/relay.env is the EnvironmentFile:
ssh host 'sudo grep -E "SIGNUP_DAILY_CEILING|CREATE_SPACING_MINUTES|ALTCHA_TRIGGER_COUNT|ALTCHA_MAXNUMBER|HIGHVALUE_NAME_POLICY|HIGHVALUE_SHORT_NAME_THRESHOLD|SEQUENTIAL_DETECTOR_ENABLED|SEQUENTIAL_THRESHOLD|SEQUENTIAL_WINDOW_MS|SEQUENTIAL_MIN_PREFIX" /etc/morphit/relay.env'
# Expect: 10 lines matching the §38.7 diamond-hardened values.
# Confirm the relay actually parsed them — hit /relay/v1/limits or
# whatever your relay's introspection endpoint surfaces. At
# minimum, journalctl should show the relay logging its loaded
# config on boot:
ssh host 'sudo journalctl -u morphit-relay --since "1 hour ago" | grep -E "ceiling|altcha|sequential"'
```
**Backup actually wrote + actually went off-host (§31, §37.12):**
```sh
# Local backup dir has recent backups
ssh host "ls -la $(grep BACKUP_DIR /etc/morphit/backup.env | cut -d= -f2 | tr -d \"'\")"
# Off-host destination has them too
ssh backups@your-backup-host 'ls -la /morphit/' | head -10
# Expect: recent .age files; size > 0; mtime within the last 24h
# Spot-test decryption with the age key (NOT on the morphit host!)
age -d -i /path/to/backup.key /tmp/sample-backup.sql.gz.age | head
# Expect: the start of a pg_dump (-- PostgreSQL database dump --)
```
**Application surface — relay + indexer respond + serve the right
JSON:**
```sh
curl -sf https://yourinstance.example/v1/instance | jq '.disabled_assets'
# Expect: an array (may be empty); confirms the indexer is up,
# /v1/instance is responding, and the cp6 disabled_assets field is
# wired.
curl -sf https://yourinstance.example/relay/v1/health
# Expect: 200 + JSON; confirms BunkerWeb is proxying to the relay
# and the relay is alive.
```
If any check above fails, fix that subsection before moving on
— a partial hardening pass with one broken layer is worse than
honest about the gap, because operational decisions will be
made assuming the layer is in place.
### 37.20 Active-key defense-in-depth — beyond the OS baseline
Subsections 37.137.19 above harden the **operating system** the
relay runs on. This subsection layers on top: even after the OS
baseline is in place and verified, the active key still sits in
the relay's process memory and on disk (encrypted). The items
below add successive layers above the OS so that compromise of
the OS itself doesn't immediately mean compromise of the key.
Each item declares: **why it helps**, **what it costs**, and
**when it makes sense to add it**. The ordering is roughly by
value-per-effort — early items are cheap and high-value; later
items are operationally heavier but raise the ceiling further.
**The baseline starting point assumed by this subsection:**
- §3 boot-time passphrase ceremony with `systemd-creds`
- §37.5 systemd process / capability hardening (`PrivateTmp`,
`ProtectSystem=strict`, etc.)
- §37.7 AppArmor profile for `morphit-relay`
- §37.10 secrets-file hygiene (mode `0400`, owned by
`morphit-relay`, AIDE-monitored via §37.9)
- §3 in-process key-handling discipline (KDF buffer zeroed
after use, decrypted WIF only in JS string scope)
Do not start on the items below until the baseline is in place
and the §37.19 verification checklist passes. Layering
defense on top of a broken baseline is wasted effort.
#### 37.20.1 — YubiKey HMAC-SHA1 challenge-response as boot passphrase
**Why it helps.** Today, the boot-time passphrase is something
the operator types. An attacker with hypervisor access to the
VPS can power-cycle the box and wait for an auto-restart with a
cached passphrase — or, if the operator is using `systemd-creds`
encrypted with the TPM, hope to extract the credential. Adding
a YubiKey challenge-response step means the relay literally
cannot decrypt its envelope without the physical YubiKey
inserted in a USB port. The challenge lives in the boot
script; the YubiKey computes HMAC-SHA1(challenge, slot-2-secret)
and the result is the passphrase.
**What it costs.**
- Hardware: $45 for a YubiKey 5 (USB-A or USB-C variant — either
works; YubiKey 5 NFC also works if you want the NFC option).
Order TWO. One stays plugged into the server; one is the
backup, stored offline in a safe. Both keys must be
programmed with the SAME slot-2 secret so they're
interchangeable.
- Setup time: ~1 hour. Mostly programming the YubiKey slot via
`ykman` + writing the unlock script that calls `ykchalresp`
and pipes the result into `systemd-creds`.
- Operational change: at boot, the relay won't come up until
the YubiKey is plugged in. This means **unplanned reboots
require physical access** (or a known-trusted remote-KVM
with KVM-over-IP smart-card passthrough). Plan accordingly.
**When it makes sense.** As soon as the relay is on a
production server you don't physically touch daily. The
defense is strongest precisely when the operator is NOT in the
data center — which is when remote reboots are the threat.
**Operational caveat — paper backup of the challenge response.**
Program the YubiKey slot-2 secret OFF-DEVICE first (compute the
secret on your laptop with `dd if=/dev/urandom bs=20 count=1 |
xxd -p`), then load it into both YubiKeys. Mail an envelope
containing the hex secret to yourself (paper, multi-location).
A lost-AND-stolen pair of YubiKeys is not fatal: regenerate
from the paper backup onto new YubiKeys.
**Source.** No code change required. Wire as a boot-script
addition that runs before `systemctl start morphit-relay`.
Reference implementation:
```bash
#!/bin/sh
# /usr/local/sbin/morphit-relay-unlock-with-yubikey.sh
# Run by the operator interactively at boot, before starting
# the morphit-relay service. Touches the YubiKey (you'll see
# the LED blink — press the button when it does), computes the
# challenge response, and feeds it into the relay's encrypted
# envelope passphrase.
set -eu
CHALLENGE_FILE=/etc/morphit/yubikey-challenge
CRED_NAME=morphit-relay-passphrase
if [ ! -f "$CHALLENGE_FILE" ]; then
echo "ERROR: $CHALLENGE_FILE missing — see §37.20.1" >&2
exit 1
fi
# Compute response; ykchalresp blocks until the user touches
# the YubiKey button.
CHALLENGE=$(cat "$CHALLENGE_FILE")
RESPONSE=$(ykchalresp -2 "$CHALLENGE")
# Pipe into systemd-creds to recreate the encrypted credential
# for this boot only. The credential lives in tmpfs and is
# wiped when the service stops.
echo -n "$RESPONSE" | systemd-creds encrypt --name="$CRED_NAME" \
- /run/credentials/morphit-relay/passphrase
systemctl start morphit-relay
echo "✓ morphit-relay started"
```
#### 37.20.2 — `mlock` + `MADV_DONTDUMP` on the decrypted-key buffer
**Why it helps.** Standard process-memory hygiene above the
existing memzero-after-use pattern. `mlock()` pins the live
key page in RAM so the kernel cannot page it to swap (where it
would persist after process termination unless swap is
encrypted). `madvise(MADV_DONTDUMP)` marks the page as
excluded from coredumps — defense in depth alongside
`fs.suid_dumpable=0` from §37.3 and `LimitCORE=0` from
§37.5. Together they harden against: (a) post-compromise
swap-scraping, (b) any accidental coredump path that §37.3
missed, and (c) `ptrace`-based memory inspection from a
non-root process running as `morphit-relay`.
**What it costs.**
- Code: ~15 lines in `apps/relay/src/crypto/keyEnvelope.ts`.
Wrap the decrypted-WIF buffer's lifecycle with libsodium's
`sodium_mlock()` + `sodium_munlock()`, which call
`mlock()`/`madvise(MADV_DONTDUMP)` under the hood (and
`memzero` on `munlock()`).
- Capability: needs `CAP_IPC_LOCK` for the relay process. Add
`AmbientCapabilities=CAP_IPC_LOCK` and
`CapabilityBoundingSet=CAP_IPC_LOCK` to the morphit-relay
systemd unit (these go alongside the existing §37.5
capability lockdown).
- Runtime overhead: negligible (a single 32-byte page locked
for the lifetime of the relay).
**When it makes sense.** Now. This is a code change in the
relay's crypto layer that costs an afternoon and tightens the
process-memory model regardless of any OS-level defense state.
**Verification.**
```sh
# From the morphit-relay user, with the relay running:
sudo -u morphit-relay cat /proc/$(pgrep -f morphit-relay)/status | grep -E 'VmLck|CoreDumping'
# Expect:
# VmLck: 4 kB (or some multiple of page size)
# CoreDumping: 0
```
#### 37.20.3 — Out-of-band signature alerts (first 4-6 weeks of operation)
**Why it helps.** The relay's existing matrix-bot integration
can DM the operator on every chain op the relay broadcasts.
Each DM includes: monotonic sequence number, op type
(`account_create` / `transfer` / `custom_json`),
recipient account (for transfers), BLURT amount (for
transfers), and timestamp. The operator sees in real time
whether anything anomalous gets signed. Catches compromise
within minutes instead of days.
This is observability, not prevention — but on a fresh
production relay where traffic patterns aren't yet baselined,
the asymmetric value of catching a compromise EARLY is huge.
After 4-6 weeks of legitimate-pattern data, the human-eye
oversight stops scaling and you turn it off (or keep it
filtered to anomalies only).
**What it costs.**
- Code: a `postBroadcastAlert(op)` hook in the relay's broadcast
path. Wire to the matrix-bot send API.
- Operator attention: ~30 DMs per day from a tester-scale
relay; check them at coffee, lunch, end of day. Use a
dedicated Matrix room so they don't drown other DMs.
- After-baseline tuning: filter to only transfers above $1
USD-equivalent, or only `account_create` ops, or
only ops to recipients not in the operators table.
**When it makes sense.** Days 042 of beta. Disable (or
filter heavily) after that, once you trust the patterns.
**Source.** `apps/relay/src/blurt/client.ts` (the relay's broadcast
methods — `broadcastTransfer`, `broadcastAccountCreate`, etc.) is the
natural site; add an `alertSink` next to the existing logging sinks.
#### 37.20.4 — In-app signer-policy fence
**Why it helps.** Even if an attacker gets code-execution
inside the relay (e.g., via a malicious upstream npm dependency
breaking past the lockfile pin, or a 0-day in a runtime
dependency), they have to go through the relay's own signer
helper to broadcast — and that helper enforces business-logic
constraints regardless of caller. The fence rejects:
1. **Transfer recipient not in `operators` table AND not a
freshly-created signup account.** The relay legitimately
transfers BLURT only to (a) other operators (fee splits) or
(b) brand-new signups (welcome bonus). An attacker
transferring to a fresh attacker-controlled account would
fail (b)'s monotonicity gate.
2. **Per-recipient 24h cumulative cap.** No single recipient
can drain more than $X per day. Configurable per operator.
3. **Global per-minute transfer rate ceiling.** N transfers
per minute max; an attacker trying to burst-drain hits this
wall.
4. **`account_create` only when ALTCHA + invite-HMAC
re-verify at sign time.** Closes a race where the
anti-bot evidence was valid at request-time but the actual
sign happens later; the signer re-checks.
This is the natural home for the spending-limit logic
discussed in cp47 kill-switch territory — extended from
"refuse everything" to "refuse anything outside policy."
**What it costs.**
- Code: a new module living alongside the existing
`apps/relay/src/policy/killSwitch.ts` — call it whatever you
like (the obvious name is "signerPolicy" but that's a
bikeshed choice). ~150300 lines including smoke coverage.
- Configuration: 46 new env vars (`MORPHIT_RELAY_DAILY_RECIPIENT_CAP_USD`,
`MORPHIT_RELAY_GLOBAL_TPM_CEILING`, etc.).
- Operational tuning: the first week, you'll watch alerts (37.20.3)
for false-positive policy rejections and tighten/loosen as needed.
**When it makes sense.** Best paired with 37.20.5 (air-gapped
signer); the policy fence and the signing primitive belong
next to each other. If 37.20.5 is in your plan, do them
together. If not, do this alone — still meaningfully reduces
attacker leverage.
#### 37.20.5 — Air-gapped signer process
**Why it helps.** Today, the active key is decrypted into the
SAME process that handles HTTP requests, talks to Postgres,
runs npm dependencies, parses JSON from chain RPC, etc. Any
remote-code-execution vector in that process gives the attacker
the key in memory.
Air-gapped-signer means moving signing into a separate process,
running as a separate Unix user, with no network egress, that
talks to the relay over a Unix-domain socket. The relay sends:
`{"op": "transfer", "to": "alice", "amount": "5.000 BLURT"}`.
The signer (a) re-validates the request against the §37.20.4
policy fence, (b) signs with the active key in its OWN process
memory, (c) returns the signed bytes back over the socket.
Compromise of the relay process now means:
- ✗ No filesystem access to the key envelope (signer's user
owns the file, mode 0400)
- ✗ No memory access to the decrypted key (separate process,
different ASLR layout, different cgroup, denied `ptrace`)
- ✓ An RPC interface to the signer — but that interface is
exactly the §37.20.4 policy fence
The signer process is tiny, audited, deliberately
feature-frozen. The relay process is allowed to evolve
rapidly; the signer is treated as cryptographic infrastructure.
**What it costs.**
- Code: ~500800 lines for a minimal signer + socket protocol.
Could be Rust, Go, or Node.js — whichever the team is most
comfortable security-auditing. Rust gets you memory-safety
guarantees the JS process doesn't have.
- Deployment: a second systemd unit (`morphit-relay-signer.service`),
a separate Unix user (`morphit-relay-signer`), a Unix socket
with restrictive permissions, an AppArmor profile
specifically for the signer that denies network egress
entirely.
- Operational change: minimal once deployed. Relay restart no
longer prompts for the passphrase (signer holds it); signer
restart does. Decouples the two lifecycles.
**When it makes sense.** After 37.20.137.20.4 are in place
and stable. This is the largest architectural change in the
ladder; it should be a deliberate sprint, not a side project.
Target it for around the first quarterly maintenance window.
#### 37.20.6 — Quarterly active-key rotation
**Why it helps.** Even with all the layers above, a
sufficiently determined attacker who somehow extracts the
active key could sit on it indefinitely, waiting for a
high-value window. Rotating the key on a calendar bounds the
window: any silent compromise has a 90-day shelf life.
§3 already documents the rotation procedure (owner key signs
the new active-authority on chain; the old active becomes
useless the moment the chain confirms). This subsection just
says: **make it a scheduled discipline, not a reactive one.**
**What it costs.**
- 30 minutes every 90 days. Plus the ~1015 minutes of
one-time setup to put a calendar reminder somewhere visible
(Matrix bot pings on the 1st of every 3rd month; cron job
emails the operator; whatever fits your workflow).
- Owner-key handling for the actual rotation: must remain
offline. See §3 for the cold-signing flow.
**When it makes sense.** Set the calendar reminder today.
First rotation: 90 days after relay first goes live.
#### 37.20.7 — YubiHSM 2 — hardware key isolation
**Why it helps.** The endgame for hot-signing-key protection.
Replace the encrypted-envelope-on-disk model with a hardware
security module: the active key is generated INSIDE the YubiHSM
and physically cannot be extracted from it. Every chain op
becomes an API call to the HSM ("here is a 32-byte hash;
please sign it with key handle 0x0042"). The HSM signs and
returns 65 bytes. The relay never sees the key.
Even root on the box can't read the key. Even physical
removal of the HSM doesn't yield the key — the HSM stores it
in tamper-resistant silicon and self-destructs the key
material on tamper detection.
YubiHSM 2 supports secp256k1 natively (Blurt's curve), which
not every HSM does.
**What it costs.**
- Hardware: ~$650 for YubiHSM 2. Buy two — primary + backup
with the same key material (HSM-to-HSM cloning via the
audit-log mode).
- Code: ~200400 lines. Replace the in-process signer
primitive (`sign(payload, wif)`) with a YubiHSM RPC call
(`yubihsm.sign(payload, key_handle)`). If §37.20.5
air-gapped-signer is already in place, this slots into the
signer process — the rest of the relay doesn't change.
- One-time provisioning: ~2 hours to set up the HSM, generate
the key inside it, configure audit-log mode, mirror to the
backup HSM.
**When it makes sense.** When morphit.io has measurable
transaction volume and a real treasury balance that justifies
the capex. Until then, 37.20.1 (cheap YubiKey for boot
unlock) + 37.20.5 (air-gapped signer) gets you 80% of the way
there for $45 + an afternoon.
#### 37.20.8 — Native Blurt 2-of-2 multi-auth with cold cosigner
**Why it helps.** Blurt accounts support weighted multi-key
authorities natively. Set the relay account's active
authority to weight-1 + weight-1, threshold 2: a key on the
relay box (weight 1) AND a key held offline by the operator
(weight 1). Every broadcast requires BOTH signatures.
Even total compromise of the hot key + the YubiHSM cannot
move funds: the chain rejects single-signature broadcasts on
this account. The attacker must also compromise the
operator's offline key, which is a different threat model
entirely (physical access to the operator).
**What it costs.**
- Operational: every broadcast must be cosigned offline. The
natural pattern is batch-signing: the relay queues ops; the
operator goes online every 6 hours, reviews the queue,
cosigns valid ops, and lets the relay broadcast the
fully-signed bytes.
- That 6-hour batch-sign cadence is fundamentally incompatible
with on-demand free signups (which expect an account_create within
seconds). Compatibility options: (a) accept a 6-hour SLA
on signups, which is awful UX; (b) split the account
topology so a fast-cycle "signup mint" key is single-sig
on the relay while a slow-cycle "operator payout" key is
2-of-2; (c) skip this layer entirely.
- Code: minimal once the multi-auth payload format is
understood; chain-side support is in place.
**When it makes sense.** Only if you accept a 6-hour-batch
signup SLA (option b above) or if signup account_create ops have been
delegated to a separate key with its own narrow authority.
Probably skip for v1.0; revisit if a real compromise event
forces the question.
---
### 37.20 — summary table
| # | Item | Cost | Value | When |
|---|---|---|---|---|
| 37.20.1 | YubiKey challenge-response boot passphrase | $45 + 1 hr | High — defeats unattended remote reboot | Now |
| 37.20.2 | `mlock` + `MADV_DONTDUMP` on key buffer | Afternoon | Medium — closes swap + coredump gaps | Now |
| 37.20.3 | Out-of-band signature alerts | 1 day + ops attention | High during beta-1 | Days 042 |
| 37.20.4 | In-app signer-policy fence | Week + smoke coverage | High — bounds blast radius | Before/with 37.20.5 |
| 37.20.5 | Air-gapped signer process | Sprint | Very high — isolates key from main process | First quarterly window |
| 37.20.6 | Quarterly active-key rotation | 30 min/90 days | High — bounds silent-compromise window | Calendar from launch day |
| 37.20.7 | YubiHSM 2 — hardware key isolation | $650 + sprint | Maximum — key never extractable | When volume justifies capex |
| 37.20.8 | Native Blurt 2-of-2 multi-auth | Operational complexity | Maximum — but breaks on-demand signups | Probably skip for v1.0 |
The natural sequencing:
- **Week 1 of beta:** 37.20.1 (YubiKey passphrase), 37.20.2
(`mlock`), 37.20.3 (alerts), 37.20.6 (calendar reminder).
- **Weeks 26:** 37.20.4 + 37.20.5 together (policy fence and
air-gapped signer; they belong next to each other).
- **Quarterly:** 37.20.6 (rotation) fires automatically from
the calendar reminder.
- **When morphit.io has real volume:** 37.20.7 (YubiHSM 2)
slots into the air-gapped signer.
- **Probably never (or only after an incident):** 37.20.8.
Don't try to do all of these at once. Each is a layer; each
needs its own verification step (which is why every subsection
above declared what success looks like). Layering hardening
without verification at each step compounds risk rather than
reducing it — see §37.19 preamble.
## 38. Diamond-hardened squatter defense — operator playbook
Squatter-driven account creation is the **single largest financial risk** to a Morphit relay. Every successful squatter signup costs the relay ~100 BLURT (the chain's account-creation fee) regardless of who's behind it. An attacker burning $1/day on a residential proxy + automated PoW solver could in theory consume the entire daily ceiling of the relay's runway.
This section is the tactical guide for an operator who wants their relay locked down as tightly as possible against squatters specifically. It complements §18 (which is the reference doc for the layered defense stack) by walking through what to configure, what to monitor, and what to do when you suspect an attack.
### 38.1 Set strict defaults for every squatter-relevant knob
Drop these into your relay's `Environment=` directives or `/etc/morphit/relay.env`:
```ini
# Layer 2 — global daily ceiling. 50 is the default; lower is
# tighter. For a small instance starting out, 25 is a sensible
# tighter posture.
MORPHIT_RELAY_SIGNUP_DAILY_CEILING=25
# Layer 3 — per-IP spacing. 60 minutes (default) lets a real
# user retry-after-failure but bounds an attacker on one IP to
# 24 signups per day even if they bypass everything else.
MORPHIT_RELAY_CREATE_SPACING_MINUTES=60
# Layer 5 — Altcha proof-of-work trigger. Default 3 = first
# two attempts frictionless. Drop to 2 to cap real-user
# friction at "1 invisible 1s PoW for the third+ attempt"
# while making attackers pay PoW from the second invite onward.
MORPHIT_RELAY_ALTCHA_TRIGGER_COUNT=2
# Layer 7 — high-value name policy. STRICT. This is the
# whole point of Layer 7 — moderate or off mode here negates
# the protection.
MORPHIT_RELAY_HIGHVALUE_NAME_POLICY=strict
# Layer 7 short-name threshold. 4 is default. Lower to 3 if
# you want to allow 4-char names (some operators may want this
# for branding). Higher (5+) blocks more — at the cost of
# shutting out users with legitimately short preferred names.
MORPHIT_RELAY_HIGHVALUE_SHORT_NAME_THRESHOLD=4
# Layer 8 — sequential pattern detector. Enable.
MORPHIT_RELAY_SEQUENTIAL_DETECTOR_ENABLED=true
# Layer 8 — block on the THIRD sequential signup (default).
# Lower to 1 to block on the SECOND if you've seen any
# sequential signups in the last 24 hours.
MORPHIT_RELAY_SEQUENTIAL_THRESHOLD=2
# Layer 8 — rolling window. Default 1 hour. Longer (e.g.
# 86_400_000 = 24h) catches slow attackers who pace signups
# beyond an hour. Tradeoff: more memory + more false-positive
# risk on legitimate batch workflows.
MORPHIT_RELAY_SEQUENTIAL_WINDOW_MS=3600000
```
Restart the relay:
```sh
sudo systemctl daemon-reload
sudo systemctl restart morphit-relay.service
# Verify the boot log shows your settings took effect:
journalctl -u morphit-relay.service --since "1 minute ago" \
| grep -E 'highvalue|sequential|loaded'
```
### 38.2 Monitor what's getting blocked
The relay logs structured events for every Layer 7 and Layer 8 rejection. Watch them:
```sh
# Live tail — useful during incident response
journalctl -u morphit-relay.service -f \
| grep -E 'highvalue_name_rejected|sequential_pattern_rejected'
# Last 24 hours — count by category
journalctl -u morphit-relay.service --since "24 hours ago" \
| grep -oE 'classification":"[a-z_]+"' \
| sort | uniq -c | sort -rn
# Last 24 hours — bucket keys involved (Layer 8 only)
journalctl -u morphit-relay.service --since "24 hours ago" \
| grep sequential_pattern_rejected \
| grep -oE 'bucketKey":"[^"]+"' \
| sort | uniq -c | sort -rn
```
Volume baseline for a healthy relay: **near zero** of either event type. A handful per week of `highvalue_name_rejected` is normal (curious users testing what's allowed). Anything above ~10/day is worth investigating.
### 38.3 The five attacker patterns to recognize
| Pattern | Signature | Defense |
|---|---|---|
| **Brand grab** | Single signup attempt for `nike` / `bitcoin` / `apple` from a fresh IP | Layer 7 `dictionary_brand` rejection |
| **Short-name farm** | Bursts of 3-char and 4-char names, varied prefixes, one IP | Layer 7 `short_name` rejection |
| **Sequential enumeration** | `acct001`, `acct002`, `acct003` — same /24, within hours | Layer 8 `sequential_numeric_suffix` rejection |
| **Distributed enumeration** | Same naming pattern but spread across many /24 buckets | Layer 7 catches each one if pattern matches; ceiling caps total |
| **Slow drip** | One signup per hour, varied long-prefix names, no obvious pattern | Daily ceiling caps it; LOW_BALANCE alert (Layer 6) raises the flag |
### 38.4 Periodic audit — review recent registrations
Schedule a weekly review. Look at your relay account on `blocks.blurtwallet.com` and inspect the last 50 `account_create` operations. Watch for:
- Names that look generic / patterns / brand-adjacent (e.g., `mybitcoin01`, `cryptotrader-fast`). These are squatter resale candidates that slipped past Layers 7-8 because they had long prefixes.
- Accounts with NO follow-up activity (no posts, no Morphit orders, no transfers). A real user creates an account TO USE it; squatters never log in.
- Sequential creation timestamps suspiciously close together.
If you see a pattern that's NOT being caught:
1. Add the specific names + close variants to `RESERVED_NAMES` in `apps/relay/src/policy/name.ts`. (Pre-launch this is a normal ad-hoc tightening; post-launch you'd issue a release.)
2. If they share a brand or dictionary signature, add to `DICTIONARY_BRANDS` or `COMMON_DICTIONARY` in `apps/relay/src/policy/highValueName.ts`.
3. Re-deploy and the next attempt at the pattern is blocked.
### 38.5 If you suspect an active attack
1. **Don't panic-flip the kill-switch yet.** Layers 7 + 8 may already be doing their job. Check the structured logs first (38.2 above). If the volume of `highvalue_name_rejected` and `sequential_pattern_rejected` spikes, the system is working correctly and the attacker is wasting their request budget.
2. **Check the operator-balance alert (§16).** If the relay's BLURT is draining despite the rejections, attempts ARE getting through. Most likely cause: the attacker is using long-prefix non-pattern names that pass Layer 7 + 8. In that case, drop to:
```ini
# Tighten Layer 8 to block on the SECOND sequential signup
MORPHIT_RELAY_SEQUENTIAL_THRESHOLD=1
# Lower the ceiling to bound damage
MORPHIT_RELAY_SIGNUP_DAILY_CEILING=10
```
3. **If you're STILL bleeding** — flip the kill-switch:
```ini
MORPHIT_RELAY_SIGNUP_ENABLED=false
```
Restart. Investigate. Don't re-enable until you understand what changed.
4. **Refill BLURT only when needed.** Don't auto-top-up during an active attack — you're handing the attacker more ammunition. Wait until the kill-switch is on, attack subsides, then refill.
5. **Post-incident: tighten the dictionaries.** Add the actual names the attacker registered to `RESERVED_NAMES` and `DICTIONARY_BRANDS` so they can't be re-registered.
### 38.6 Network-layer defenses against squatters
Layer 7-8 defenses run AFTER an attacker reaches the relay. Network-layer defenses keep them away in the first place. In addition to §32 (BunkerWeb) and §34 (UFW + fail2ban), a squatter-paranoid operator can:
**a. Run the relay behind Tor / I2P only, with a clearnet mirror sitting in front.**
Squatters typically don't route through anonymity networks because the latency disrupts their automation. A relay only reachable via Tor onion address has natural friction. The clearnet mirror (BunkerWeb terminating TLS, proxying to localhost relay) gives normal users a fast path; the Tor address gives privacy-conscious users a private path. Both are documented in `RUN-A-MORPHIT-NODE.md` §11.
**b. Country-block from low-cost residential-proxy markets.**
This is operator's-call and ethically fraught — Morphit serves worldwide users. But if you're under active attack from a specific country and your user base is regional, a temporary `BLACKLIST_COUNTRY` (BunkerWeb) or geoip-based UFW rule narrows the attacker's options without breaking your real users.
**c. ASN-block from cheap-VPS providers.**
Some hosting providers (DigitalOcean, Hetzner, OVH on certain ranges) are over-represented in attack traffic because they're cheap and don't scrutinize signups. Block their ASNs at the BunkerWeb layer (§32 advanced WAF tuning, item 5) if you see concentrated traffic from one. Real users almost never connect from a hosting provider's ASN — they're on residential ISPs.
**d. Require a `Referer:` header for `/v1/account/invite`.**
Browsers send this; bots often don't. Easy to bypass for a serious attacker but kills the lazy ones. Add to BunkerWeb:
```yaml
# Reject /v1/account/invite without a referer
USE_BLOCK_REFERRER_NONE=yes
BLOCK_REFERRER_NONE_URL=/v1/account/invite
```
This is friction-only — not a real defense — but it filters the bot-script-using-curl tier of attacker.
### 38.7 The "diamond-hardened" preset
If you want maximum squatter defense and accept the user-friction tradeoff, copy this entire block into your relay's environment:
```ini
# === DIAMOND-HARDENED SQUATTER DEFENSE ===
# Documented in OPERATIONS.md §38.7. Apply when squatter-
# defense is your primary concern and you accept moderately
# higher friction for real users.
# Layer 1 — kill-switch starts ON (signups enabled)
MORPHIT_RELAY_SIGNUP_ENABLED=true
# Layer 2 — tight daily ceiling
MORPHIT_RELAY_SIGNUP_DAILY_CEILING=20
# Layer 3 — 90 minutes between same-IP signups
MORPHIT_RELAY_CREATE_SPACING_MINUTES=90
# Layer 5 — PoW from the SECOND invite onward
MORPHIT_RELAY_ALTCHA_TRIGGER_COUNT=2
# 2x the default difficulty — ~2s on modern phone, ~4s on old
MORPHIT_RELAY_ALTCHA_MAXNUMBER=4000000
# Layer 7 — strict; 5-char minimum
MORPHIT_RELAY_HIGHVALUE_NAME_POLICY=strict
MORPHIT_RELAY_HIGHVALUE_SHORT_NAME_THRESHOLD=5
# Layer 8 — block on SECOND sequential, 24-hour window
MORPHIT_RELAY_SEQUENTIAL_DETECTOR_ENABLED=true
MORPHIT_RELAY_SEQUENTIAL_THRESHOLD=1
MORPHIT_RELAY_SEQUENTIAL_WINDOW_MS=86400000
MORPHIT_RELAY_SEQUENTIAL_MIN_PREFIX=3
```
The user-visible cost of this preset:
- 5-char minimum names (rejects 3 + 4-char preferred names some users want)
- Real users see Altcha PoW from their second attempt
- 90-minute lockout if a user fails their first signup attempt
- 20 max signups per day (caps growth — tighten OFF as your instance proves stable)
If your goal is "absolute minimum BLURT loss, accept user friction," this is the configuration. Watch the rejection logs (§38.2) to make sure you're not accidentally blocking a flood of legitimate users; if you are, dial back §38.1 instead.
---
## 39. Operating a home-hosted instance — concerns specific to running on residential internet
This section is the operator-grade reference for issues that come up only when your Morphit instance is running on a residential internet connection (Pi or laptop in the operator's house, as opposed to a rented VPS).
The grandma-friendly setup walkthrough — CGNAT detection, DDNS hostname registration, port forwarding, fixed local IP, HTTPS — is in `docs/RUN-A-MORPHIT-NODE.md` §3 (the home-hosting section new operators read before §4). **§3 is the quick first-time setup; THIS section is the deeper ongoing-operations reference for after the box is online** (residential-WiFi Postgres binding, IPv6, energy cost, off-site backups, Tor fronting).
### 39.1 Uptime monitoring over a flaky home link
A residential internet connection has more outages and more ISP-driven hiccups than a datacenter VPS. Two changes from the standard monitoring posture (OPERATIONS.md §13 references `/v1/health`):
- **Don't alert on a single failed probe.** Configure your uptime monitor (UptimeRobot, BetterStack, self-hosted Uptime Kuma, etc.) to alert only after **3 consecutive failures with at least 2-minute spacing**. Cable / DSL / fiber transient drops of 30-90s are routine and don't represent a real outage.
- **Probe from multiple geographic regions.** If you only probe from one location and that probe shares a backbone with your home ISP, you'll see false outages during peering disputes. Most monitoring services let you pick 2-4 probe locations for free; pick ones in different continents.
### 39.2 Restarting after power loss
Cover all three legs of the restart story:
- **The hardware** — BIOS / UEFI "AC Power Recovery" set to "Power On" or "Last State" (laptop), or default Pi auto-boot. Set this when you first provision the machine so it comes back up after a power blip.
- **The OS services** — `systemctl is-enabled morphit-indexer morphit-relay morphit-backup.timer` should all return `enabled`. If any are `disabled`, run `systemctl enable` for them. Test annually by issuing `sudo reboot` and confirming everything comes back without manual intervention.
- **The encrypted-key passphrase** — if you're using the encrypted-envelope form for `MORPHIT_RELAY_ACTIVE_KEY_FILE` (`apps/relay/scripts/encrypt-active-key.ts`), the relay prompts for the passphrase on stdin at boot. **A reboot from outside your house — for example, the UPS dying during a long outage — will leave the relay waiting for the passphrase indefinitely.** Two mitigations:
1. Configure the relay's systemd unit with `StandardInput=tty-force` AND a wrapper service that emails you when the relay is stuck waiting for input. You then SSH in and supply the passphrase.
2. OR run the relay with the **plaintext-WIF form** of the key file (mode 0400, owned by the relay user, on an encrypted filesystem volume). This trades passphrase-at-boot for at-rest disk encryption. For a single-operator residential deployment, the disk-encryption-at-rest posture is usually appropriate; the passphrase-at-boot ceremony was designed for VPS deployments where the disk substrate isn't yours.
### 39.3 ISP terms of service
Most consumer ISPs technically prohibit "running servers" in their TOS, but enforcement is essentially never on real-world traffic — they care about open relays, port-25 spam abuse, and copyright-infringing torrent endpoints. A small Morphit instance handling normal user traffic is invisible to ISP enforcement. **Realistic risk: very low.** The two failure modes that have actually triggered ISP attention historically:
- **Sustained heavy upload bandwidth** (>50% of advertised cap, sustained over weeks). Morphit's bandwidth profile is bursty and small (a few KB per orderbook fetch, infrequent). Not a concern unless you have thousands of users.
- **Outbound spam reports.** This requires the relay's account to be compromised in a way that lets attackers send abusive ops, which is a security incident regardless of where you're hosted. Standard incident response (§7) applies.
If your ISP does send a TOS warning, the diplomatic response is "I'm running a personal cryptocurrency wallet — I can move it to a dedicated hosting provider if needed." Most ISP TOS teams accept this and don't escalate. The diplomatic-but-firmer response is "I'm running a small piece of software for my personal use and consuming less than 1% of my advertised bandwidth; please point me at the specific TOS clause you believe I'm violating." Most contact-center staff don't have an answer to that.
### 39.4 What if you move?
A home-hosted instance is tied to your physical address until you do something about it. When you move:
- **If you're moving to a new home WITH a non-CGNAT ISP**, the migration is straightforward: power down at the old address, transport the hardware, plug in at the new address, repeat the router port-forward setup from `RUN-A-MORPHIT-NODE.md §3` (the new router won't have your old rules), update DDNS (the script will pick up the new IP automatically within 5 minutes). Total downtime: a few hours during transport.
- **If you're moving to an apartment with CGNAT or a hotel for a month**, you can't host from there. Two options: (a) leave the hardware powered on at the old address temporarily if you have a friend/family at that address willing to host it for a few weeks, or (b) migrate to a VPS. To migrate: pick the VPS path in `RUN-A-MORPHIT-NODE.md §2`, set up the new VPS using §5§9 of that guide, point your domain at the VPS's IP (the A-record step in §4), and decommission the home machine. Your operator account, your fees account, and your reputation all stay the same — **users see no change** because nothing about the service identity is tied to the IP.
### 39.5 Cleartext local Postgres traffic in a residential WiFi context
OPERATIONS.md §14 establishes that the indexer and relay listen on `127.0.0.1` only (loopback) and Postgres connections are loopback-only. **This is still correct for home hosting** — but home networks have a quirk worth flagging.
If your Pi or laptop is on a wired ethernet connection to your router, Postgres-on-loopback is exactly as private as on a VPS. **If it's on WiFi**, the situation is the same as long as Postgres is bound to `127.0.0.1` (which is the default per `RUN-A-MORPHIT-NODE.md §5`'s setup) — WiFi doesn't change anything because the loopback interface doesn't traverse WiFi.
The risk only emerges if you accidentally bind Postgres to `0.0.0.0`. Verify periodically:
```
ss -tlnp | grep 5432
# Expected: 127.0.0.1:5432 only
# DANGER: 0.0.0.0:5432 or *:5432 (then any device on your home WiFi can connect)
```
A misconfigured Postgres on WiFi exposes it to every guest device on your network — including the smart TV that has a known CVE you didn't patch. Loopback-only is the right binding.
### 39.6 IPv6 considerations
Many residential ISPs are IPv6-by-default now. Two quick checks:
- **Does your home have a public IPv6 prefix?** Run `ip -6 addr show` on the Morphit machine; look for an address in `2000::/3` range (i.e., starts with a 2 or 3). If yes, you have IPv6.
- **Does your registrar's dynamic-DNS update URL support IPv6?** Many do — append the provider's IPv6 field to the update URL (often something like `&ipv6=$(curl -s6 ifconfig.co)` alongside the `{ip}` IPv4 field). Check your registrar's dynamic-DNS documentation for the exact parameter.
If you have IPv6 and want to publish AAAA records alongside your CNAME, add an `AAAA` record at `@` and `www` pointing at your machine's GUA address. Modern browsers prefer IPv6 when available, which can improve user experience for IPv6-enabled visitors and reduce the load on your IPv4 NAT.
Most home operators leave IPv6 disabled at the router level and run IPv4-only — that's also fine. The home-setup walkthrough in `RUN-A-MORPHIT-NODE.md §3` works either way.
### 39.7 Energy-cost monitoring
Optional — but if you're tracking the cost of running your home node:
- **Pi 4** drawing ~5W at idle, ~6W under typical Morphit load. At US average $0.16/kWh, that's `5W × 24h × 365d × 0.001 × 0.16 = $7.01/year`.
- **Old laptop** (10-15 years old) drawing ~20W at idle, ~25W under load. `25 × 24 × 365 × 0.001 × 0.16 = $35.06/year`.
- **Old desktop** (10-15 years old) drawing ~50W idle, ~80W under load. `80 × 24 × 365 × 0.001 × 0.16 = $112.20/year` — at this point, the VPS path is cheaper.
A $20 plug-in power meter (Kill-A-Watt or equivalent) gives you the actual number for your hardware. Worth doing once.
### 39.8 Backups — the off-site copy is mandatory for home operators
OPERATIONS.md §31 documents the daily local Postgres backup (`/home/morphit/backups/`). For home hosters specifically, **the off-site backup is mandatory, not optional**. The reason is the threat model: a VPS operator who loses the local backup still has the VPS provider's snapshot of the disk. A home operator who loses the local backup to a fire / flood / theft has lost everything.
The recommended off-site backup pattern:
- **Free option (S3-compatible bucket)**: `rclone` to a Backblaze B2 bucket (10 GB free, $0.005/GB/month after; a Morphit DB compresses to under 1 GB). Daily upload added as a step at the end of the systemd backup unit.
- **Privacy-preserving option (encrypted off-site)**: `rclone` with `crypt` backend pointed at the same B2 bucket. The bucket sees only encrypted blobs.
- **Self-hosted option (offsite friend/family)**: rsync over ssh to a relative's NAS or another Pi at a different physical address. Tradeoff: free, but the friend's setup has to stay running too.
Whatever you pick: **test the restore at least once a quarter.** Untested backups have a catastrophic-failure rate; tested ones don't. The procedure is in OPERATIONS.md §31 ("Quarterly restore drill").
### 39.9 Network-level privacy considerations specific to home hosting
A home-hosted Morphit instance leaks **your home's public IP address to every user who connects**. Most users don't care, but for an operator with a public-facing role under their real name, this can be a low-grade privacy concern. Mitigations:
- **Front the instance with a Tor onion service** (covered in OPERATIONS.md §11). Users who care can connect via the onion address; the home IP is only revealed to clearnet users. The Tor onion address itself reveals nothing about your home IP.
- **Move to a VPS.** The VPS provider sees your home IP (because that's where you SSH in from), but users see only the VPS IP. This is the path most operators take when home-hosting visibility becomes a concern.
Whichever you pick, it doesn't have to be permanent. The ability to migrate without disrupting users is the whole point of the federated, no-user-data model.
## 40. Treasury chain-pin + XMR per-payment proofs — broadcasting and verifying
This section is the operator-facing reference for **the
canonical Morphit operator** (currently `@morphit`) to
broadcast and rotate the treasury chain-pin shipped in Part
106 (2026-05-10), corrected in Part 107 (privacy fix —
view key removed from chain-pinned data), and structurally
improved in Part 108++ (per-payment tx_proof verification —
no view key required by any indexer).
If you are a **community operator** running your own
Morphit instance, skip to §40.7. Part 108++ removed the
previous three-options dilemma: every operator can now
verify XMR fees independently, no shared secret needed.
### 40.1 What the chain-pin does and why it exists
Pre-Part-106, every operator's indexer trusted its own
`MORPHIT_INDEXER_BTC_FEE_ADDRESS` /
`MORPHIT_INDEXER_XMR_FEE_ADDRESS` env vars as gospel. A
hostile fork could silently change those env vars to a
hostile address and divert all BTC/XMR fees from users on
that instance. ADR-0011's 2026-05-09 amendment said
"BTC/XMR fees: 100% to treasury (`@morphit-fees`)" — the
**policy** — but **no code enforced the addresses
themselves**.
Part 106 closes that gap by extending the existing signed
`morphit_release_v1` op (already authenticated by the
`@morphit` posting key via the trust anchor pinned in
`apps/web/src/lib/net/config.ts`) with an optional
`treasury` block containing BTC/XMR addresses + amounts.
Every federated indexer prefers the chain-pinned canonical
over its own env-var fallback. The frontend reads the
same chain-pinned addresses and renders them with copy +
QR + chain-pinned badge.
### 40.2 Three priorities: how Part 108++ realizes them
Morphit's three priorities, in order:
1. **Privacy & anonymity** for users.
2. **Decentralization** — no central authority, no
chokepoints, every instance fully sovereign.
3. **Grandma-friendly UI/UX** — usable by people who
have never used crypto.
Pre-Part-108++, XMR verification required the operator
to hold the treasury wallet's private view key in env on
their box. Even with Part 107's fix (key never on chain),
this still meant **only canonical morphit.io** could
verify XMR fees — community operators inheriting
canonical's chain-pinned address had no view key, so
they faced a three-options dilemma:
- (a) Trust canonical's federated verdict — needed a
federation-trust path that didn't exist.
- (b) Run their own treasury wallet — deviates from
canonical, visible in federation.
- (c) Disable XMR fees — cleanest but reduces user
options.
That dilemma violated priority #2: every community
operator was effectively dependent on canonical
morphit.io's existence and willingness to verify XMR.
Federation tolerates instances disappearing — but only
for the chains an instance can verify locally. XMR
broke that.
**Part 108++ resolves it** with Monero's standard
per-payment proof mechanism. The user generates a
proof from their own wallet after paying; any indexer
verifies the proof against the txid + treasury address
using a public Monero block explorer (or a local
`monerod` for maximum independence). Properties:
- **Privacy:** the proof reveals only "this txid paid
this address this amount." No other wallet activity,
no other payments to the address, no metadata. The
user is the only party that holds any verification
secret (their tx_key, in their own wallet, never
published). Indexers hold nothing.
- **Decentralization:** every indexer verifies every
payment independently using public information. No
shared secret, no central instance. Canonical
morphit.io is one indexer among many.
- **Grandma-friendliness:** trade-off — the user must
generate a proof from their wallet (one extra step
vs. just pasting a txid). Mitigated by inline
per-wallet instructions (CLI / GUI / Cake / Feather)
in 10 locales, expandable on the post-order page.
### 40.3 What ships on the operator's box (Part 108++)
For the canonical morphit.io operator:
- **Public**: BTC address, XMR address, fee amounts.
These go on chain via `morphit_release_v1`'s
`treasury` block.
- **Operator-private**: nothing XMR-specific anymore.
The Part 107-era `MORPHIT_INDEXER_XMR_FEE_VIEWKEY`
env var was removed entirely in Part 109. No view
key lives on any operator's box.
For community operators:
- **Public**: nothing — they inherit canonical's
chain-pinned XMR address automatically.
- **Operator-private**: nothing. XMR verification works
out of the box on every Morphit instance with no
shared secret.
### 40.3a The BLURT fee base is chain-pinned too (cp372), and auto-tracked
Until cp372 the BTC/XMR fee **amounts** were chain-pinned but the
**BLURT** fee base was a per-operator env var
(`MORPHIT_INDEXER_FEE_BASE_BLURT`) — a per-node BLURT floor that
could fork the orderbook. cp372 closes that: the `treasury` block
now also carries `blurt: { base }`, resolved chain-pin → env exactly
like BTC/XMR, so **every federated indexer enforces the same BLURT
floor**. Community operators do NOTHING — you inherit the
chain-pinned base; `MORPHIT_INDEXER_FEE_BASE_BLURT` is now just your
Plan-B fallback / local override (used only on a fresh node, or to
deliberately run a different local floor).
**Auto-re-pin (maintainer-only).** The enforced amount stays a
fixed chain-pin (no price read in the verifier → no fork, no
quote→pay race); the *displayed* fee tracks the live canonical USD
target (~12.5¢ BLURT, ~25¢ BTC/XMR). As the market drifts the pin
goes stale, so `ops/systemd/morphit-treasury-repin.timer` (config:
`ops/env/treasury-repin.env.example`) runs a daily drift check and
re-pins only past a 10% threshold that sits **inside** the
verifier's 15% band (quotes are never rejected mid-drift). The
DEFAULT is **detect-only** (no key): it emits a `treasury_repin_due`
alert and you broadcast by hand — `release-build-payload.ts` (now
prompts for a BLURT fee base) into the laptop-only, key-gated
`release-broadcast.ts` (the **Plan B**, no key online). Opt-in
auto-broadcast (`MORPHIT_REPIN_ENABLE_AUTO_BROADCAST=1` + a `0600`
key file) builds+signs+broadcasts unattended — **trusted signing
box ONLY, never the production server** (the posting key can re-pin
the treasury). Failsafes: a down/zero/negative feed skips that
asset (never re-pinned from a bad price), an over-ceiling computed
amount is rejected, one bad feed never blocks a healthy asset, and a
fetch failure aborts with no re-pin. A re-broadcast keeps the same
version (no spurious update banner), updating only the amounts.
### 40.4 Choosing your XMR explorer backend
The XMR fee verifier sends `(txid, address, proof)`
over HTTPS to one or more Monero block explorers'
`/api/outputs?txprove=1` endpoint to verify each
per-payment proof. You choose how many explorers to
ask, and which.
**The default ships with five.** How strong the cross-check
actually is depends on `MORPHIT_INDEXER_XMR_MIN_SUCCESSFUL_RESPONSES`
— the number of explorers that must AGREE on the proven amount
before a result is accepted. **Its default is `1`**, which means
any single responding explorer's amount is trusted (the verifier
takes the largest agreeing group, and a group of one qualifies).
**For real cross-check — so that a single compromised or coerced
explorer cannot decide a verification — raise it to `2` or more**
(`3` is a strong setting against the five-explorer default). At
the default of 1 you get availability, not agreement-based
defense.
```bash
MORPHIT_INDEXER_XMR_EXPLORER_URLS=https://xmrchain.net,https://localmonero.co/blocks,https://monerohash.com/explorer,https://exploremonero.com,https://moneroexplorer.org
```
These five all run the
`moneroexamples/onion-monero-blockchain-explorer`
reference codebase — same API surface, same JSON shape.
They are operated by independent parties. If you want
to add more or use different ones, the only constraint
is API compatibility: the URL must expose
`/api/outputs?txhash=…&address=…&viewkey=…&txprove=1`
returning JSON with `status: "success"` and
`data.outputs[*]: {amount, match}`.
> **Monero note — that `viewkey=` parameter does NOT carry a real
> view key.** It is the `onion-monero-blockchain-explorer`'s own
> API naming. Combined with `txprove=1`, the explorer interprets
> the value as a **single-use transaction proof** (the
> `OutProof…` string the payer generated with `get_tx_proof`),
> NOT a wallet view key. Morphit never holds, transmits, or logs
> a treasury view key — there isn't one (see §12 and §40.2). The
> indexer puts the payer's per-payment proof in that slot; it
> reveals only "this txid paid this address this amount" and
> nothing else about any wallet. (The indexer also logs only the
> explorer's base URL, never the full URL with the proof.)
**Explorers known to be API-compatible (5):**
- `https://xmrchain.net` (reference instance, run by
moneroexamples)
- `https://localmonero.co/blocks`
- `https://monerohash.com/explorer`
- `https://exploremonero.com`
- `https://moneroexplorer.org`
**Explorers known to be NOT API-compatible:**
- `https://xmrscan.org` — different codebase
- `https://blockchair.com/monero` — different API
shape, no `txprove=1` endpoint
- `https://monero.bar` — lightweight network-health
dashboard (block height, difficulty, hashrate, pool
distribution, RPC node status); useful for operators
eyeballing the state of the Monero network and for
spot-checking RPC node availability, but **not** a
full block explorer and does NOT expose the
`/api/outputs?txprove=1` endpoint. Do not add to
`MORPHIT_INDEXER_XMR_EXPLORER_URLS`. Bookmark it as
a sidebar tool, not a verification source.
**Option 1: Public multi-explorer (default).** No
operator setup. Cross-check among five independent
parties. Each one sees the same per-payment data
(txid, address, proof) at verification time; none of
them accumulates any wallet-level secret (the proof is
single-payment). This is the recommended default for
new operators.
```bash
# (this IS the default — set explicitly only if
# you want to customize the list)
MORPHIT_INDEXER_XMR_EXPLORER_URLS=https://xmrchain.net,https://localmonero.co/blocks,https://monerohash.com/explorer,https://exploremonero.com,https://moneroexplorer.org
```
**Option 2: Self-hosted Monero block explorer + local
monerod (priority #2 maximum independence).** Spin up
your own monerod and `monero-block-explorer` on the
operator box; point the verifier at localhost. No
third-party sees any verification request.
```yaml
# In docker-compose.yml on your operator box:
# (Pin both images to specific tags — never `:latest` — for
# reproducibility. Update by checking the upstream pages for
# current stable releases before each deploy.)
services:
monerod:
# Check https://github.com/sethforprivacy/simple-monerod-docker/pkgs/container/simple-monerod
# for the current Monero stable release; pin to that tag.
image: ghcr.io/sethforprivacy/simple-monerod:v0.18.4.1
volumes:
- ./monero-data:/home/monero/.bitmonero
command:
- --restricted-rpc
- --rpc-bind-ip=0.0.0.0
- --rpc-bind-port=18081
- --confirm-external-bind
- --no-igd
- --enable-dns-blocklist
networks: [internal]
block-explorer:
# Locally-built image (the `build:` directive below compiles
# from source). Pin the local tag so `docker compose up`
# rebuilds deterministically when the upstream changes.
image: morphit-xmrblocks:v1
build:
context: https://github.com/moneroexamples/onion-monero-blockchain-explorer.git
depends_on: [monerod]
command: >
./xmrblocks
--daemon-url=monerod:18081
--enable-json-api
--enable-ssl
ports:
- "127.0.0.1:8081:8081"
networks: [internal]
networks:
internal:
driver: bridge
```
Then in `/etc/morphit/indexer.env`:
```bash
MORPHIT_INDEXER_XMR_EXPLORER_URLS=https://localhost:8081
```
(Use HTTPS via a local reverse proxy with a self-signed
cert, or relax the HTTPS-only check by patching your
own build — the public default enforces HTTPS for
network-bound calls; for `localhost`, the constraint is
defensible-in-depth, not security-critical.)
What this option costs: ~50 GB disk for the Monero
chain, sync time ~3-7 days, and ongoing block ingestion
(low CPU, but persistent). For high-volume operators
who care about priority #2 maximum independence, this
is the right answer.
**Option 3: Hybrid (recommended for security-conscious
operators).** Multiple explorer URLs combining
self-hosted + public. Detect manipulation: if your
self-hosted result ever disagrees with the public ones,
you have evidence.
```bash
MORPHIT_INDEXER_XMR_EXPLORER_URLS=https://localhost:8081,https://xmrchain.net,https://localmonero.co/blocks
```
**How disagreement is handled.** The verifier groups the
responding explorers by the amount each one proves and takes the
**largest agreeing group** ("bucket"). It accepts that bucket's
amount once the bucket has at least
`MORPHIT_INDEXER_XMR_MIN_SUCCESSFUL_RESPONSES` members (default
`1`); a minority reporting a different amount is simply outvoted,
not treated as a hard error. Non-responding explorers (timeout,
network error, circuit-breaker open) are skipped — they don't
block the verification, and the breaker handles per-explorer
flakiness. If no amount gathers enough agreeing explorers, the
verifier returns `rejected` with reason `quorum not met: best
group had < N agreeing explorers (…)` (or `all N explorers in
cooldown` when every explorer is circuit-broken). Raising the
quorum above 1 is what turns a minority outlier into a rejection
worth investigating — a compromised explorer, a chain reorg, or a
stale view at one explorer.
**Failure modes by config size.**
- 1 explorer: any outage stops XMR verification (orders
wait in `pending_external`). Any compromise lies
undetected.
- 2 explorers: outages tolerated by either; lies
detectable as long as both don't lie identically.
- 5 explorers (default): high availability + strong
cross-check. Two would need to be compromised
collude-style to lie undetected.
- 5 explorers including self-hosted: as above PLUS
the self-hosted result is authoritative-to-you;
divergence is evidence rather than a coin-flip.
**These detection properties assume you have raised
`MORPHIT_INDEXER_XMR_MIN_SUCCESSFUL_RESPONSES` to ≥2.** At the
default of `1`, the largest single response is trusted and a lie
from a lone responding explorer is NOT detected — more explorers
only add availability, not cross-check, until the quorum is
raised.
### 40.5 Generating the keys (one-time, before first broadcast)
You need:
- A dedicated **Bitcoin address** for treasury inflows.
- A dedicated **Monero address** for treasury inflows.
- A way to **broadcast a signed `custom_json` op from
`@morphit`** — typically a Blurt-aware wallet (Vessel,
blurt-cli, beempy, dblurt-script).
**Bitcoin address.** Create a fresh wallet for treasury
inflows. Native segwit (`bc1q...`) is recommended for
the lower miner fees. Whatever Bitcoin wallet you use
is fine; just make sure the seed is backed up offline
and the spending key never reaches the morphit.io
production server.
**Monero address.** Create a fresh wallet for treasury
inflows:
```
$ monero-wallet-cli --generate-new-wallet=morphit-treasury.wallet \
--restore-height=<recent-block> \
--mnemonic-language=English
# Inside the prompt:
[wallet]: address # → primary address (95 chars, starts with `4`)
[wallet]: spendkey # NEVER publish this — it lets anyone spend your funds.
[wallet]: seed # Back up the 25-word seed offline.
```
**Note (Part 108++):** you do NOT need to extract or
record the wallet's private view key. Earlier parts of
this guide (105/106/107) instructed operators to put the
view key in env; that's no longer required. XMR
verification uses per-payment proofs from users'
wallets, which are checked against the public address
alone.
**Note (Part 110): the previous `verify-xmr-viewkey.ts`
diagnostic-only helper script was retired in Part 110.**
Wallet creation can be sanity-checked end-to-end with the
modern flow: configure `MORPHIT_INDEXER_XMR_FEE_ADDRESS`,
restart the indexer, have a trusted contact send a small
test payment with a tx_proof, and submit it through the
real Morphit UI. If the order verifies, your XMR
configuration is correct. This exercises the exact code
path users will hit.
### 40.6 Broadcasting the release op
Once your keys are ready, build a `morphit_release_v1`
payload carrying the `treasury` block. Full shape
(Part 108++ — no viewkey field):
```json
{
"version": "1.0.0",
"hash_manifest": {
"/index.html": "sha256-...",
"/_app/...": "sha256-..."
},
"endpoints": {
"blurt_rpc": [
"https://rpc.blurt.blog",
"https://rpc.beblurt.com"
]
},
"signature": "(optional)",
"treasury": {
"btc": {
"address": "bc1q...",
"satoshis": 416
},
"xmr": {
"address": "4...",
"piconero": "781250000"
}
}
}
```
A helper script generates this for you. Note the
`hash_manifest` is NOT hand-typed — you derive it from the
VPS's served `/verify.json` with
`apps/web/scripts/verify-json-to-release-manifest.mjs` (step 0
below), never a laptop build; you give the builder its path.
See the numbered flow below for the exact commands.
The script:
- Takes the version, manifest-file path, and endpoints
(via prompts on a TTY, or the `MORPHIT_BUILD_*` env vars)
- Pre-fills the BTC/XMR treasury from the canonical
addresses baked into `canonicalTreasury.ts`
- Validates against the same rules the indexer enforces
- Refuses to emit any payload containing a 64-hex
string (defense against accidentally including a view
key — the Part 107 invariant carried forward into
108++)
- Does NOT prompt for the view key
Sign + broadcast as a `custom_json` op:
```javascript
{
required_auths: [],
required_posting_auths: ["morphit"],
id: "morphit_release_v1",
json: "<the JSON string you just built>"
}
```
Sign with the `@morphit` **private** posting key (the WIF, starts "5…" — NOT the public posting key). This key lives
**off** the morphit.io production server, on a personal
machine you trust — typically your laptop.
You do NOT need to hand-assemble that op or find a wallet
that broadcasts arbitrary `custom_json`. The repo ships a
helper that does it from the same machine, using the same
Blurt library the relay uses (`@beblurt/dblurt`):
```
# 0) derive the SRI hash manifest from the VPS's SERVED /verify.json —
# NOT a laptop build. Vite/Rollup output is not byte-reproducible
# across machines, so a laptop-built manifest won't match the deployed
# bundle and trips the frontend's "Build integrity check failed"
# banner. Upgrade the VPS FIRST (`morphit-ops upgrade`) so /verify.json
# reflects the new bundle, then convert its tamper-critical BOOTSTRAP
# subset (shell + service worker + entry loader — stays under the
# indexer's 4 KB per-field JSONB cap; /verify.json keeps the full
# per-file coverage):
curl -fsSL https://<your-instance>/verify.json -o ~/verify.json
node apps/web/scripts/verify-json-to-release-manifest.mjs ~/verify.json \
> apps/web/build-manifest.release.json
# 1) build the payload — BTC/XMR treasury pre-filled from
# apps/indexer/src/config/canonicalTreasury.ts; you supply the
# version and the manifest from step 0. (cp436 — endpoints are no
# longer pinned on-chain; omit MORPHIT_BUILD_ENDPOINTS_FILE.)
# < /dev/null forces non-interactive mode so the redirected
# stdout is clean JSON (not echoed prompts):
MORPHIT_BUILD_VERSION=<semver> \
MORPHIT_BUILD_HASH_MANIFEST_FILE=apps/web/build-manifest.release.json \
npx tsx apps/indexer/scripts/release-build-payload.ts < /dev/null > release.json
# 2) PREVIEW — prints the exact op, asks for NO key, sends nothing:
npx tsx apps/indexer/scripts/release-broadcast.ts release.json --dry-run
# 3) sign + broadcast for real (prompts for the PRIVATE posting key / WIF, masked):
npx tsx apps/indexer/scripts/release-broadcast.ts release.json
```
`release-broadcast.ts` re-validates the payload, refuses any
64-hex secret, reads the posting WIF from a MASKED prompt
(never a file, never an env var, never logged), shows you the
derived public key to eyeball before sending, then broadcasts
across the default RPC nodes. **LAPTOP ONLY** — never run it
on the production server; that box must never hold the posting
key.
### 40.7 For community operators (running your own Morphit instance)
Default behavior:
1. **Leave `MORPHIT_INDEXER_BTC_FEE_ADDRESS` empty.**
Your indexer inherits the chain-pinned canonical
BTC address. Users on your instance pay BTC fees
to the canonical Morphit treasury.
2. **Leave `MORPHIT_INDEXER_XMR_FEE_ADDRESS` empty.**
Your indexer inherits the chain-pinned canonical
XMR address.
3. **You still get your 90% operator share on
BLURT-paid fees** (separate pipeline, see §28).
Only BTC/XMR fees go 100% to canonical's treasury;
BLURT fees split 90/10 to you.
4. **Choose your XMR explorer backend** (§40.4 above).
Default ships with FIVE independent Monero explorers
(xmrchain.net, localmonero.co/blocks,
monerohash.com/explorer, exploremonero.com,
moneroexplorer.org) running the same reference codebase
but operated by independent parties. With
`MORPHIT_INDEXER_XMR_MIN_SUCCESSFUL_RESPONSES` raised to ≥2 this
cross-check rejects single-source manipulation (§40.4; the
default of 1 trusts a single responding explorer).
Self-host a `monero-block-explorer` Docker container
against your own `monerod` for maximum independence.
5. **No view key needed, no shared secret needed.**
Every Morphit instance verifies XMR fees
independently using user-submitted per-payment
proofs. Canonical morphit.io has no privileged
role in your verification.
If you want to run your own treasury wallet (collect
XMR fees yourself instead of forwarding to canonical),
fill in `MORPHIT_INDEXER_XMR_FEE_ADDRESS` with your
own address. Your XMR-fee orders won't appear on
canonical's orderbook (the txid paid your address, not
canonical's), but you keep 100% of the XMR. Federation
health monitors comparing `/v1/release.treasury` will
show the divergence. Permitted but visible.
### 40.8 Verifying federation propagation
Within a few minutes of broadcasting a release op,
every federated indexer should reflect the new pin in
its `/v1/release` response. Verify by polling:
```
# Canonical
curl https://morphit.io/v1/release | jq .treasury
# Community operators (from /v1/instances list)
for instance in alice.example.com bob.example.org; do
echo "== $instance =="
curl -sS "https://$instance/v1/release" | jq .treasury
done
```
Each response should show the **same** `treasury`
object you broadcast. Specifically, every response's
`treasury.xmr` should have **only `address` and
`piconero` fields** — no `viewkey` (Part 107
invariant; the response is passed through `stripViewkey`
on output as defense-in-depth).
### 40.9 Rotating the addresses later
To rotate the BTC or XMR address (e.g., new wallet),
broadcast a **new** `morphit_release_v1` op with
updated treasury fields. The indexer's `/v1/release`
query returns "the most recent valid release," so the
new op supersedes the previous one within ~3 seconds.
**Don't rotate frequently.**
- BTC: rarely (every few years, if at all).
- XMR address: as needed. Pre-Part-108++, XMR
rotation also required a coordinated env-update.
Now it's just the chain-pin update. No more env
coordination overhead.
If you do rotate, **announce it on the Matrix channel
`#agorise:matrix.org` and the canonical Morphit Blurt
account's blog before broadcasting**, so users in
flight can wait out the transition.
### 40.10 The keys reference table
| Key | Account | Where it lives | Used for | Frequency |
|---|---|---|---|---|
| Posting | `@morphit` | YOUR personal laptop, OFF the morphit.io server | Release ops (incl. treasury chain-pin) | Rare (4-12/year) |
| Active | `@morphit-relay` | `/etc/morphit/keys/relay-active.key`, mode 0400, encrypted envelope | Account creation, operator payouts, all relay broadcasts | Constant (hundreds/day at scale) |
| Owner (both accounts) | `@morphit` and `@morphit-relay` | Paper, in a safe, off any networked machine | Active-key rotation, posting-key rotation | Almost never |
| **XMR private view key** | **morphit-treasury wallet** | **NOT REQUIRED on operator box (Part 108++; env var removed Part 109).** Stays in your wallet's seed/keystore for personal access only. | None — diagnostic script retired Part 110. | **Generated once, never read by indexer code, never on chain, never in any API.** |
| Posting (per-user, syndication) | Each individual user | User's own keychain / in-page WIF unlock | Their own syndication posts | Per-user |
| **XMR tx_proof** | **The user, per payment** | User's own Monero wallet (CLI / GUI / Cake / Feather) | Verifying THIS specific XMR fee payment. Submitted with the order op, per payment. | Per-payment, user-generated |
The treasury chain-pin specifically uses the
`@morphit` posting key — the key you keep off the
production server.
The Part 108++ design eliminates the operator's role
in holding any XMR-specific secret on the production
box. The user is the only party that holds anything
verification-related (their per-payment tx_key in their
own wallet, used to generate proofs and never
published).
### 40.11 Migration path from Part 107
If you're upgrading from a Part 107 deployment:
1. **Pull and deploy** the Part 109 release. Your
`MORPHIT_INDEXER_XMR_FEE_VIEWKEY` env var line (if
present) is harmless — zod ignores unknown env vars,
so the line is silently dropped. You can delete the
line from `/etc/morphit/indexer.env` next time you
touch the file.
2. **Restart the indexer.** It will start using
`MoneroProofFeeVerifier` (per-payment proofs) instead
of the deleted `MoneroExplorerFeeVerifier` (view-key
decryption).
3. **No coordinated migration with users.** Pre-Part-
108++ XMR orders that submitted only a txid will be
rejected by the new structural validator with reason
`tx_proof_required_for_xmr` — but pre-launch, there
are zero such orders, so this is a no-op.
4. **You can safely remove your view key from env**
once you're confident the new path works. Or leave
it; it's ignored.
5. **Optional:** if you want priority #2 maximum
independence, set up a self-hosted Monero block
explorer (§40.4 option 2) and switch
`MORPHIT_INDEXER_XMR_EXPLORER_URLS` to point at it.
Pre-launch (zero live instances), there's no user-
impact migration concern. Post-launch, the same
upgrade path would require coordinating with users
about the new tx_proof requirement — but that
coordination simply doesn't apply yet.
## 41. Federation-cost attribution — only paying for ops served by YOUR instance
**Origin:** Part 111 (2026-05-10).
### What this section covers
How Morphit's federation guarantees that each
operator's relay pays only for the ops that route
through their own instance — not for ops happening
on every other operator's instance in the
federation.
### The problem (pre-Part-111)
Morphit's federation worked correctly for the
orderbook layer: every indexer in the federation
saw every Morphit op on chain, and every indexer
kept a consistent view of orders, feedback, and
operator-attribution events.
But the **payout** layer had a gap. Five payout
categories are queued to `relay_pending_transfers`
(which the relay drainer broadcasts):
| Payout | Trigger |
|---|---|
| Account creation chain fee | HTTP endpoint on the relay (`/v1/account/create`) |
| Welcome bonus (20 BLURT) | `morphit_feedback_v1` op on chain with `order_permlink` |
| Low-balance dust refill (~1 BLURT) | Scanner finds user active in last N days, balance below threshold |
| Operator-payout (90% of BLURT fee) | `morphit_order_v1` on chain with `operator_tag` |
| Loyalty milestone BP delegation | Cumulative-BLURT-paid threshold crossed |
Account creation was correctly scoped — an HTTP
endpoint on the relay, so only the operator the
user actually hit pays. The other four were not:
each was triggered by an on-chain op that EVERY
indexer in the federation processed independently.
Result: N operators in the federation → N× relay
spend on every payout-triggering op.
### The fix (Part 111)
Use the existing `operator_tag` field on order ops
as the gate. Each operator's indexer compares the
op's `operator_tag` against the indexer's own
`MORPHIT_INSTANCE_OPERATOR_TAG` env var; only the
operator named on the op queues the payout. Other
operators see the op, record it for orderbook /
audit / federation-consistent global state, and
skip the payout queue insert.
Why this is robust:
- The 90% operator-payout flows to the operator
named by `operator_tag`. The same operator is
also obligated for the consequences (10% treasury
via the BLURT fee transfer, welcome bonus,
refills, loyalty BP). Economic alignment: a
spammer attacking by attributing to a victim
operator must pay 90% of every fee TO that
victim — net break-even, zero leverage.
- `operator_tag` is already on chain, already
public. Zero new on-chain data, zero new privacy
leak.
### Configuration
`MORPHIT_INSTANCE_OPERATOR_TAG` in
`morphit.config.env`. Canonical morphit.io uses
`morphit`. Community operators pick their own
(e.g. `example-community`).
```
# In morphit.config.env:
MORPHIT_INSTANCE_OPERATOR_TAG=morphit
```
Wizard step 18 captures this at `morphit-ops init`
time. Same step is reachable via
`morphit-ops edit Operator tag (federation attribution)`
for ongoing maintenance.
### Conservative default
If `MORPHIT_INSTANCE_OPERATOR_TAG` is unset, the
indexer treats every op as "for a different
operator" and queues NO payouts. The relay does
nothing. Better to pay nothing than to pay for
ops you can't prove are yours.
A community operator who skips wizard step 18 will
see their indexer running fine (orderbook updates,
chat works, fee verification works) but their
relay queue will be empty. The fix is to set the
env var and restart the indexer, or re-run
`morphit-ops edit` to pick the section.
### Community-operator onboarding sequence
For a community operator standing up
`example-community.com`:
1. **Pick your operator tag.** Wizard step 18
prompts, and now defaults it to your domain
(e.g. `example-community.com`) — a great choice
since it's unique and recognizable. Constraints:
lowercase letters, digits, dots, underscores,
hyphens; 1..64 chars. Cannot equal an
already-registered operator's tag, and cannot be
a project-reserved name (`morphit`, `agorise`,
etc.) — the wizard blocks those up front. This
tag is also what's shown publicly: your entry in
the federated `/instances` directory and on your
`/about-this-instance` page. It's permanent once
registered.
2. **Register on chain.** Run `npx morphit-ops
register`. It broadcasts
`morphit_operator_register_v1` from your operator
account claiming the tag — using
`MORPHIT_INSTANCE_OPERATOR_TAG` (so the registered
tag and your earnings tag match by construction).
First-come-first-served; once claimed, no other
operator can use it. Before broadcasting it shows
the public key your active key derives to; if the
account can't cover the operation's small BLURT fee
(Blurt charges a per-op fee from your LIQUID balance —
it does NOT gate transactions on mana/RC like
Hive/Steem; see docs/BLURT-CHAIN-MODEL.md) it tells you
to top up a little liquid BLURT (≈5 BLURT is ample —
transfer it, do NOT power it up) and lets you retry in
place — no full re-run. See the
morphit_operator_register_v1 handler in
apps/indexer/src/indexer/handlers for the op shape.
- To verify the saved key at any time:
`npx morphit-ops show-key` prints the public key
it derives to (never the private key) so you can
compare it to your account's active authority on
a Blurt explorer.
3. **Restart the indexer.** It will pick up the
new env var and start queueing payouts for ops
carrying your tag.
4. **Verify with a test order.** Have a trusted
contact post an order through your instance,
pay the BLURT fee, then leave feedback citing
the order. Your relay should queue +20 BLURT
welcome bonus + 1 BP delegation + the 90%
operator-payout share. Check
`SELECT * FROM relay_pending_transfers WHERE
recipient = '<that account>'`.
If steps 2-4 yield nothing in the queue, the most
likely cause is steps 1+3 — verify the env var
matches what `morphit_operator_register_v1`
claimed.
### What "served by us" means in practice
An order op carrying `operator_tag: <YOUR-tag>` was
submitted by a user through your instance's
frontend (the frontend writes the tag from your
indexer's `/v1/instance.operator_tag` endpoint).
That op's payouts (operator-payout, loyalty BP,
welcome bonus if the user leaves feedback) are
your relay's obligation.
An order op carrying `operator_tag: <other-tag>`
was submitted through another instance. Your
indexer records it (for federation-consistent
orderbook + audit) but queues nothing.
### What stays consistent across the federation
These are NOT gated by operator tag — every
indexer in the federation keeps the same view:
- `orders` table contents (modulo `operator_tag`
column, which records which operator served the
op)
- `feedback` table contents
- `account_loyalty.cumulative_blurt_paid` per user
- `account_loyalty_milestones` rows
- `accounts.first_trade_complete_at` per user
- `operator_attribution_events` per op
Why: these are "what happened on chain" — every
indexer must agree to keep the orderbook and audit
trail consistent.
These ARE gated:
- `relay_pending_transfers` rows (only your
operator's are queued by your indexer)
- `operator_payouts` audit rows (only when you
queue the payout)
- `operator_earnings` rolling totals (only your
operator's)
### Tests + smoke
- `apps/indexer/test/indexer/federationScopeGate.test.ts`
— 11 scenarios covering all 4 gating sites with
both gate-passes and gate-fails flows.
- `operatorEarnings.attributeBlurtFeeToOperator`
returns `attributed_other_instance` when the op's
tag doesn't match — no DB writes at all in that
branch.
- `loyalty.trackVerifiedBlurtFee` extended with
`orderOperatorTag` + `instanceOperatorTag` params;
gates both first-fee welcome BP and milestone
delegation queue inserts.
- `feedback` handler looks up cited order's
`operator_tag` from the `orders` table; queues
welcome bonus only when matched.
- `lowBalanceScanner.selectCandidates` JOINs
`orders.operator_tag = MY tag` instead of the
pre-Part-111 `EXISTS ops` (which matched
federation-wide activity).
**Smoke-suite troubleshooting — `ERR_MODULE_NOT_FOUND` on
`@morphit/asset-registry` (or other `@morphit/*` packages).**
If `bash scripts/run-smokes.sh` fails several runners (typically
single digits — the count drifts each release as smokes are added
or refactored — examples that have historically been affected:
`order-handler`, `rss-orderbook`, `rss-orderbook-xml-validate`,
`edit`, `edit-rpc`, `surface-invariant`) all with the same
`ERR_MODULE_NOT_FOUND` error referencing a `@morphit/*` package,
the cause is that `npm install` hasn't been run at the workspace
root yet, so the symlinks under `node_modules/@morphit/*` that
the workspace setup creates don't exist. Fix:
```bash
cd ~/morphit # repo root, where the root package.json lives
npm install --no-audit --no-fund
```
Then re-run the smoke suite; the affected runners should pass.
This is NOT a code regression — `@morphit/asset-registry`,
`@morphit/indexer-client`, `@morphit/operator-config`, etc. are
internal packages whose source lives under `packages/`, and the
workspace symlinks under `node_modules/@morphit/*` are what let
`apps/*/src/...` resolve their imports. Pure environment setup.
### Migration
Schema migration v30 adds `orders.operator_tag TEXT`
column (nullable) + index `(operator_tag, account,
created_at)`. Pre-Part-111 rows stay NULL. Pre-
launch reality (zero live instances) means this
compat is for replay tests only.
### What "rotating to a new operator account" means
If a community operator decides to switch from
`example-community-old` to `example-community-new`,
they must:
1. Register the new tag on chain.
2. Update `MORPHIT_INSTANCE_OPERATOR_TAG` in
`morphit.config.env` and restart.
3. Update their frontend's `/v1/instance.operator_tag`
response (the indexer does this automatically
from the same env var).
Past ops with the old tag continue to credit the
old operator account (operator-payouts already
queued before the switch will still broadcast).
Going forward, new ops will carry the new tag and
queue to the new operator's relay.
Currently no automated migration — operators
rotating accounts handle this manually. Filed in
REVISIT-LIST as a defer until operational evidence
of demand.
---
## Trade-only asset configuration
**Audience:** operators deciding which trade-only assets their
instance accepts, and how transaction-explorer links resolve for
single-network trade-only assets (BCH, LTC, DASH, DOGE, ZEC, ARRR, DCR, SOL, ETH, XRP) and multi-network trade-only assets (USDT, USDC, DAI).
### How to set this (two paths)
**At install time (recommended):** the `morphit-ops init`
wizard, step 13 "Trade-only asset policy" (Part 122 cp22), walks
through every shipped trade-only asset and asks per-ticker
whether to enable it. Default for each is YES per Memory #25.
The wizard emits the right
`MORPHIT_INDEXER_DISABLED_ASSETS=` line into
`morphit.config.env` — no manual env-file editing needed.
**Post-deploy on a running instance, in your browser (cp116):**
visit `/admin/setup-wizard` on your domain. Toggle the asset
checkboxes (BTC/XMR/BLURT are locked enabled — core federation
assets), hit Copy, paste the emitted line into
`morphit.config.env`, restart the indexer
(`docker compose restart indexer`). Read-only page — never
mutates your server, no auth-gating needed. See
RUN-A-MORPHIT-NODE.md "Browser setup-wizard" for the full
operator UX.
**Post-deploy or on an existing instance:** edit
`MORPHIT_INDEXER_DISABLED_ASSETS` directly in
`/etc/morphit/morphit.config.env` (or wherever your
`EnvironmentFile=` points) and restart the indexer service.
Browsers see the change at most 5 minutes after restart (the
`/v1/instance` response carries a 5-minute `Cache-Control`
header).
All three paths write the same env var — the CLI wizard, the
browser wizard, and direct env-file editing differ only in
ergonomics. Re-running any path overwrites the previous
value; there is no merge logic.
### Disabling specific assets instance-wide
**`MORPHIT_INDEXER_DISABLED_ASSETS`** — comma-separated list of
uppercase tickers from the canonical asset registry. Orders
posted with a disabled asset are rejected at handler time with
`reason: 'asset_disabled_on_instance'`. Default empty (every
canonical-registry asset is enabled).
**Parser is tolerant** of whitespace, mixed case, and trailing
commas — write it however you like, the indexer normalizes
internally. All of these produce the same `['USDT']` value:
```bash
MORPHIT_INDEXER_DISABLED_ASSETS="USDT"
MORPHIT_INDEXER_DISABLED_ASSETS="usdt"
MORPHIT_INDEXER_DISABLED_ASSETS=" USDT "
MORPHIT_INDEXER_DISABLED_ASSETS="USDT,"
```
Multi-coin examples:
```bash
# Refuse one specific asset
MORPHIT_INDEXER_DISABLED_ASSETS="USDT"
# Refuse USDC (Part 122 cp30 — operators preferring to avoid
# a Circle-custodial stablecoin)
MORPHIT_INDEXER_DISABLED_ASSETS="USDC"
# Refuse DAI (Part 122 cp31 — operators preferring to keep
# stablecoin exposure to USDT/USDC only; see ADR-0029)
MORPHIT_INDEXER_DISABLED_ASSETS="DAI"
# Refuse Bitcoin Cash (privacy-focused operators may prefer
# BTC + XMR only)
MORPHIT_INDEXER_DISABLED_ASSETS="BCH"
# Refuse Litecoin (some operators specialize in Bitcoin +
# privacy-coin trading without BTC-fork variants)
MORPHIT_INDEXER_DISABLED_ASSETS="LTC"
# Refuse Dash (operators preferring to limit the surface to
# Bitcoin-family chains without masternode-coordinated coins)
MORPHIT_INDEXER_DISABLED_ASSETS="DASH"
# Refuse Dogecoin (Part 122 cp33 — brand/audience choice for
# operators specializing in serious-money trading; see ADR-0030)
MORPHIT_INDEXER_DISABLED_ASSETS="DOGE"
# Refuse only ZEC trades (cp39):
MORPHIT_INDEXER_DISABLED_ASSETS="ZEC"
# Refuse only ARRR trades (cp41):
MORPHIT_INDEXER_DISABLED_ASSETS="ARRR"
# Refuse only DCR trades (cp43):
MORPHIT_INDEXER_DISABLED_ASSETS="DCR"
# Refuse only SOL trades (cp45):
MORPHIT_INDEXER_DISABLED_ASSETS="SOL"
# Refuse only ETH trades (cp47):
MORPHIT_INDEXER_DISABLED_ASSETS="ETH"
# Refuse only XRP trades (cp49):
MORPHIT_INDEXER_DISABLED_ASSETS="XRP"
# Refuse BARTER (cp425 — the goods/services "asset"; e.g. a
# crypto-only instance that doesn't want off-platform barter listings):
MORPHIT_INDEXER_DISABLED_ASSETS="BARTER"
# Refuse two assets (any future stablecoin additions)
MORPHIT_INDEXER_DISABLED_ASSETS="USDT,DAI"
# Refuse three or more
MORPHIT_INDEXER_DISABLED_ASSETS="USDT,DAI,USDC"
# Refuse BCH AND USDT (focus on every other Category-B asset
# plus BTC/XMR/BLURT)
MORPHIT_INDEXER_DISABLED_ASSETS="BCH,USDT"
# Refuse all four Bitcoin-fork variants (still keeps BTC + XMR
# + BLURT + USDT + USDC + DAI + ZEC + ARRR + DCR + SOL + ETH +
# XRP enabled)
MORPHIT_INDEXER_DISABLED_ASSETS="BCH,LTC,DASH,DOGE"
# Refuse all centralized + partly-centralized stablecoins (privacy-pure operator stance: BTC/XMR/BLURT/BCH/LTC/DASH/DOGE/ZEC/ARRR/DCR/SOL/ETH/XRP only)
MORPHIT_INDEXER_DISABLED_ASSETS="USDT,USDC,DAI"
# Refuse everything that isn't BLURT + XMR + BTC (all 13
# Category-B trade-only assets disabled — keeps only the three
# Category-A fee-payable assets)
MORPHIT_INDEXER_DISABLED_ASSETS="USDT,USDC,DAI,BCH,LTC,DASH,DOGE,ZEC,ARRR,DCR,SOL,ETH,XRP"
# Whitespace-tolerant — same result as above
MORPHIT_INDEXER_DISABLED_ASSETS="USDT, DAI, USDC"
# Accept everything (default — same as omitting the var)
MORPHIT_INDEXER_DISABLED_ASSETS=""
```
**Federation semantics:** disabling an asset is OPERATOR-level,
not user-level. Orders for a disabled asset still appear in
your instance's read-only orderbook feeds (the chain history
is shared across the federation), but your indexer refuses to
accept NEW orders for that asset from your own users. Users
who prefer an instance that supports the asset switch to a
different Morphit operator — federation is the point.
**Do NOT disable Category-A (fee-payable) assets — BTC, XMR,
BLURT.** The wizard step 13 cannot offer this (the
Category-B filter excludes them). An operator manually editing
the env file to set `MORPHIT_INDEXER_DISABLED_ASSETS="BLURT"`
or similar will create a weird state: trading in that asset is
disabled, but listing-fee payments in that asset still work
(fee_method enum is independent of asset registry per Memory
#23). Don't do this. If you genuinely don't want to trade
BTC/XMR/BLURT, you're running a different product — start by
opening an issue describing what you actually want.
**Why the canonical morphit.io ships with USDT enabled:** active
traders find dollar-stable assets useful for parking value
between trades, and the canonical operator's stance is to
support that use case. Operators with different focuses
(privacy-pure, XMR-only, BTC-and-BLURT-only, etc.) override.
The federated marketplace keeps trading; users self-route to
the instance whose asset list matches their preferences.
The parser tolerance is pinned in CI by
`apps/indexer/scripts/disabled-assets-parse-smoke.ts` (12
scenarios covering empty, one coin, multi-coin, whitespace,
case, leading/trailing commas, double commas, etc.) so future
refactors can't accidentally break the multi-coin form.
Memory #23 (BLURT/BTC/XMR-only for listing fees) and Memory #25
(default-on + operator override for new assets) together define
this knob's posture. See `docs/adr/0023-usdt-multi-network.md`
for the full design.
### Frontend surfaces showing your instance's disabled-assets list (Part 121 cp6)
The `MORPHIT_INDEXER_DISABLED_ASSETS` value flows through the
indexer's `/v1/instance` endpoint as the `disabled_assets` field
(an uppercase-tickers JSON array, e.g. `["USDT"]` or
`["USDT","DAI"]`). Two frontend pages surface this to users:
- **`/about-this-instance`** renders a "This instance's asset
policy" section that shows the current disabled-assets list.
Empty array → emerald "None — this instance accepts every
tradable asset"; populated → "USDT (operator-disabled on this
instance; tradeable on peer instances)". Federation note in
the same panel reminds users that peer instances' orders still
appear in the orderbook regardless — the gate is only on NEW
orders posted from THIS instance.
- **`/run-a-node`** carries a "Your instance, your asset policy"
panel explaining the env var to prospective operators with
three pillars (default-on, opt-out env var, federation stays
intact) and a pointer back to this section of OPERATIONS.md.
The `/operators` page does NOT yet surface peer-instance
disabled-assets badges — that's deferred to a follow-on Part
(needs a v33 schema migration to cache `disabled_assets` per
peer in the `known_instances` table + a federation-probe
handler extension). Until then, users can check each peer's
own `/about-this-instance` to see its stance. REVISIT-LIST §A
"Federation-probe extension for peer-instance asset stance"
tracks this deferral.
If you change `MORPHIT_INDEXER_DISABLED_ASSETS` after deploy,
clients will see the new value at most 5 minutes later (the
`/v1/instance` response carries `Cache-Control: public,
max-age=300`). Restart the indexer service for the env-var
change to take effect; the cache header is the only delay
between restart and full propagation to all browsers.
### Per-network explorer URL overrides (USDT only)
USDT is Morphit's first multi-network asset (ERC-20, TRC-20,
SPL, BEP-20). Each network has a bundled-default explorer URL
template (etherscan.io for ERC-20, tronscan.org for TRC-20,
solscan.io for SPL, bscscan.com for BEP-20). Operators
running self-hosted alternatives override per-network via
frontend env vars exposed in the instance config payload.
Operator-config example for a privacy-conscious operator:
```bash
# Override all four — point at self-hosted instances
MORPHIT_FRONTEND_USDT_ERC20_CHAT_LINK_URL="https://my-self-hosted-blockscout.example.org/tx/{txid}"
MORPHIT_FRONTEND_USDT_TRC20_CHAT_LINK_URL="https://my-self-hosted-tron.example.org/#/transaction/{txid}"
MORPHIT_FRONTEND_USDT_SPL_CHAT_LINK_URL="https://my-self-hosted-solana.example.org/tx/{txid}"
MORPHIT_FRONTEND_USDT_BEP20_CHAT_LINK_URL="https://my-self-hosted-bsc.example.org/tx/{txid}"
```
`{txid}` is the placeholder substituted at render time with
the lowercased transaction ID. SPL txids are base58 and
case-preserved; the others are hex and lowercased.
If you choose to disable USDT instance-wide via
`MORPHIT_INDEXER_DISABLED_ASSETS=USDT`, the per-network
explorer config has no effect on your instance.
### BCH chat-link explorer URL override (Part 122 cp21)
BCH is single-network (mainnet only), so there's just one
explorer URL to think about. Like the BTC and XMR chat-link
URLs (`MORPHIT_FRONTEND_BTC_CHAT_LINK_URL`,
`MORPHIT_FRONTEND_XMR_CHAT_LINK_URL`), the BCH override is a
single env var:
```bash
# Default (bundled) — operators don't need to set anything to
# get this behavior:
# MORPHIT_FRONTEND_BCH_CHAT_LINK_URL="https://blockchair.com/bitcoin-cash/transaction/{txid}"
# Override to a self-hosted or alternative explorer:
MORPHIT_FRONTEND_BCH_CHAT_LINK_URL="https://my-self-hosted-bch-explorer.example.org/tx/{txid}"
```
`{txid}` is the placeholder substituted at render time with the
lowercased transaction ID (BCH txids are hex like BTC).
Validation: must be `https://`, must contain literal `{txid}`,
must parse as a URL after substitution. An invalid template
fails indexer startup with a clear error message rather than
silently shipping a broken link.
Alternative BCH explorers operators can point at — surveyed at
Part 122 cp21 addition time:
- https://blockchair.com/bitcoin-cash (bundled default)
- https://www.blockchain.com/explorer
- https://bitinfocharts.com/bitcoin%20cash/explorer/
- https://bchexplorer.info/
- https://www.oklink.com/bch
- https://bch.tokenview.io/
- https://blockexplorer.one/bitcoin-cash/mainnet
- https://explorer.cloverpool.com/bch
The ops-cli wizard step 12 (Chat-link external explorer URLs)
asks for the BCH URL after BTC and XMR with the same
probe-reachability check that the BTC and XMR URLs get.
If you choose to disable BCH instance-wide via
`MORPHIT_INDEXER_DISABLED_ASSETS=BCH`, the chat-link config
has no effect on your instance.
### LTC chat-link explorer URL override (Part 122 cp24)
LTC is single-network (mainnet only), so there's just one
explorer URL to think about. Same shape as BTC/XMR/BCH:
```bash
# Default (bundled) — operators don't need to set anything to
# get this behavior:
# MORPHIT_FRONTEND_LTC_CHAT_LINK_URL="https://litecoinspace.org/tx/{txid}"
# Override to a self-hosted or alternative explorer:
MORPHIT_FRONTEND_LTC_CHAT_LINK_URL="https://my-self-hosted-ltc-explorer.example.org/tx/{txid}"
```
`{txid}` is the placeholder substituted at render time with the
lowercased transaction ID (LTC txids are hex like BTC).
Validation: must be `https://`, must contain literal `{txid}`,
must parse as a URL after substitution. Invalid templates
fail indexer startup with a clear error message.
Alternative LTC explorers operators can point at — surveyed at
Part 122 cp24 addition time:
- https://litecoinspace.org (bundled default — community-led,
mempool.space-style, no JS tracking, open-source)
- https://blockchair.com/litecoin
- https://www.oklink.com/litecoin
- https://bitinfocharts.com/litecoin/explorer/
- https://chain.so/LTC
- https://blockexplorer.one/litecoin/mainnet
- https://ltc.tokenview.io/
The ops-cli wizard step 12 (Chat-link external explorer URLs)
asks for the LTC URL after BTC, XMR, and BCH with the same
probe-reachability check that the others get.
If you choose to disable LTC instance-wide via
`MORPHIT_INDEXER_DISABLED_ASSETS=LTC`, the chat-link config
has no effect on your instance.
### DASH chat-link explorer URL override (Part 122 cp27)
DASH is single-network (mainnet only), so there's just one
explorer URL to think about. Same shape as BTC/XMR/BCH/LTC:
```bash
# Default (bundled) — operators don't need to set anything to
# get this behavior:
# MORPHIT_FRONTEND_DASH_CHAT_LINK_URL="https://insight.dash.org/insight/tx/{txid}"
# Override to a self-hosted or alternative explorer:
MORPHIT_FRONTEND_DASH_CHAT_LINK_URL="https://my-self-hosted-dash-explorer.example.org/tx/{txid}"
```
`{txid}` is the placeholder substituted at render time with the
lowercased transaction ID (DASH txids are hex like BTC).
Validation: must be `https://`, must contain literal `{txid}`,
must parse as a URL after substitution. Invalid templates
fail indexer startup with a clear error message.
Alternative DASH explorers operators can point at — surveyed at
Part 122 cp27 addition time:
- https://insight.dash.org/insight/ (bundled default — official
Dash project, community-led, open-source, no third-party ads)
- https://explorer.dash.org/insight/
- https://blockchair.com/dash
- https://chainz.cryptoid.info/dash/
- https://www.oklink.com/dash
- https://bitinfocharts.com/dash/explorer/
- https://blockexplorer.one/dash/mainnet
- https://www.blockchain.com/explorer/assets/dash
- https://dash.tokenview.io/
The ops-cli wizard step 12 (Chat-link external explorer URLs)
asks for the DASH URL after BTC, XMR, BCH, and LTC with the same
probe-reachability check that the others get.
If you choose to disable DASH instance-wide via
`MORPHIT_INDEXER_DISABLED_ASSETS=DASH`, the chat-link config
has no effect on your instance.
### Single-network chat-link explorer URL overrides for DOGE / ZEC / ARRR / DCR / SOL / ETH / XRP (Part 122 cp33 / cp39 / cp41 / cp43 / cp45 / cp47 / cp49)
Each of these assets shares the same single-network, single-URL
shape as the BCH/LTC/DASH sections above. The pattern is:
```bash
# Default (bundled — operators don't set anything to get this)
# MORPHIT_FRONTEND_<TICKER>_CHAT_LINK_URL="<bundled-explorer>/{txid}"
# Override to self-hosted or alternative explorer:
MORPHIT_FRONTEND_<TICKER>_CHAT_LINK_URL="https://my-explorer.example.org/tx/{txid}"
```
`{txid}` is the placeholder substituted at render time with the
lowercased transaction ID. Validation: must be `https://`, must
contain literal `{txid}`, must parse as a URL after substitution.
Invalid templates fail indexer startup with a clear error.
The ops-cli `morphit-ops init` wizard step 12 (Chat-link external
explorer URLs) walks through all 13 single-network chat-link
explorer overrides in canonical asset-registry order
(BTC → XMR → BCH → LTC → DASH → DOGE → ZEC → ARRR → DCR → SOL →
ETH → XRP), each with the same probe-reachability check.
Bundled defaults + alternative-explorer surveys at addition time:
- **DOGE (cp33)** — bundled `https://blockchair.com/dogecoin/transaction/{txid}` (chosen from a 9-explorer survey: dogechain.info, blockchair.com/dogecoin, bitinfocharts.com/dogecoin, live.blockcypher.com/doge, blockexplorer.one/dogecoin/mainnet, blockchain.com/explorer/assets/doge, sochain.com/DOGE, chain.so/DOGE, oklink.com). See ADR-0030.
- **ZEC (cp39)** — bundled `https://zcashblockexplorer.com/transactions/{txid}` (chosen for transparent+shielded coverage; see ADR-0031). Note: shielded-only transactions don't expose data to ANY explorer by design — the chat link works for transparent payments and shows a privacy-respecting summary for shielded ones.
- **ARRR (cp41)** — bundled `https://explorer.pirate.black/tx/{txid}` (the official Pirate Chain explorer; all transactions on ARRR are shielded by construction, so the explorer shows only the proof-of-inclusion summary — no amount or recipient info ever leaks). See ADR-0032.
- **DCR (cp43)** — bundled `https://explorer.dcrdata.org/tx/{txid}` (the official Decred dcrdata explorer; see ADR-0033).
- **SOL (cp45)** — bundled `https://explorer.solana.com/tx/{txid}` (the official Solana project explorer; chosen from a 5-explorer survey). See ADR-0034.
- **ETH (cp47)** — bundled `https://eth.blockscout.com/tx/{txid}` (chosen for being an open-source non-aggregator project explorer with no SQL trackers; alternatives surveyed: etherscan.io, ethplorer.io, beaconcha.in/block-explorer). See ADR-0035.
- **XRP (cp49)** — bundled `https://livenet.xrpl.org/transactions/{txid}` (the official XRP Ledger Foundation explorer — a non-profit organization; chosen for the non-aggregator + non-Ripple-Labs criteria). See ADR-0036.
If you choose to disable any of these instance-wide via
`MORPHIT_INDEXER_DISABLED_ASSETS=<TICKER>`, the corresponding
chat-link config has no effect on your instance.
### Schema migration v32 (Part 121)
`apps/indexer/src/db/schema.sql` adds an `orders.asset_network
TEXT` column for multi-network assets. Pre-Part-121 rows
have `asset_network IS NULL`, which is the correct value for
single-network assets too (BTC, XMR, BLURT, BCH, LTC, DASH, DOGE, ZEC, ARRR, DCR, SOL, ETH, XRP
all single-network; USDT, USDC, DAI multi-network; all
single-network assets write NULL).
USDT orders carry one of `'erc20'|'trc20'|'spl'|'bep20'`;
USDC orders carry one of `'erc20'|'spl'|'base'|'polygon'`;
DAI orders carry one of `'erc20'|'polygon'|'base'|'arbitrum'`.
The migration is idempotent (`ADD COLUMN IF NOT EXISTS`) and
applied automatically on indexer startup. No operator action
required beyond the standard `npm run migrate` flow.
## Payment-method configuration
**Audience:** operators deciding which canonical payment methods
their instance offers — most commonly whether to allow **Barter**
(products/services, e.g. trading crypto for goods).
> **⚠ Two different "Barter" features — disable BOTH to fully opt out.**
> This section covers the barter **payment method** (`barter_goods`): a way
> for someone *selling a cryptocurrency* to also accept goods/services as
> payment. Since cp425 there is ALSO a first-class barter **asset**
> (`BARTER`), where the goods/services *themselves* are the listing, priced
> in local currency and settled in a crypto the seller accepts. The asset is
> controlled separately under **"Trade-only asset configuration"** above with
> `MORPHIT_INDEXER_DISABLED_ASSETS="BARTER"`. If you want NO barter of any
> kind on your instance, disable BOTH: the `barter_goods` payment method
> here **and** the `BARTER` asset there.
This is the payment-method analogue of "Trade-only asset
configuration" above. Every canonical payment method ships
ENABLED by default; an operator can turn any of them off so they
don't appear in the post-order payment picker or the orderbook
payment filter.
### How to set this (two paths)
**At install time:** the `morphit-ops init` wizard, step 14
"Payment-method policy", asks whether to offer Barter
(products/services) on your instance — the one method operators
most often disable. Answering "no" writes `barter_goods` into the
`MORPHIT_INDEXER_DISABLED_PAYMENT_METHODS=` line in
`morphit.config.env`. To disable methods *other* than Barter, set
the env var directly (next).
**On a running instance:** edit
`MORPHIT_INDEXER_DISABLED_PAYMENT_METHODS` in
`/etc/morphit/morphit.config.env` (or wherever your
`EnvironmentFile=` points) and restart the indexer
(`docker compose restart indexer`). Browsers see the change at
most 5 minutes after restart (the `/v1/instance` response carries
a 5-minute `Cache-Control` header).
### `MORPHIT_INDEXER_DISABLED_PAYMENT_METHODS`
Comma-separated list of canonical payment-method **keys**,
**lowercase**. Default empty (every canonical method is offered).
The parser tolerates whitespace, mixed case, and trailing commas.
```bash
# Offer everything (default — same as omitting the var)
MORPHIT_INDEXER_DISABLED_PAYMENT_METHODS=""
# Money-only instance — no Barter
MORPHIT_INDEXER_DISABLED_PAYMENT_METHODS="barter_goods"
# No PayPal
MORPHIT_INDEXER_DISABLED_PAYMENT_METHODS="paypal"
# Disable several
MORPHIT_INDEXER_DISABLED_PAYMENT_METHODS="barter_goods,paypal,zelle"
```
### Canonical payment-method keys
These come from the canonical registry
(`apps/web/src/lib/payments/registry.ts`). Use the **key**, not
the display name. Note the asymmetry: crypto "pay-with" methods
carry a `pay_` prefix; the online/bank methods do **not**
(`paypal`, not `pay_paypal`).
- **In person / by mail:** `barter_goods` (Barter —
products/services), `cash_in_person`, `precious_metals`,
`cash_by_mail`
- **Online / bank:** `airwallex`, `alipay`, `amazon_pay`,
`apple_pay`, `bancontact`, `bitso`, `bizum`, `blik`,
`brics_pay`, `cash_app`, `gcash`, `google_pay`, `ideal`,
`interac_etransfer`, `klarna`, `mercado_pago`, `mir`, `mpesa`,
`mtn_momo`, `oxxo_pay`, `payoneer`, `paypal`, `paytm`, `payu`,
`pix`, `przelewy24`, `revolut`, `shaparak`, `shebapay`,
`sofort`, `spei`, `square_cash`, `unionpay`, `venmo`,
`wechat_pay`, `wise`, `zelle`
- **Crypto (pay-with):** `pay_btc`, `pay_xmr`, `pay_blurt`,
`pay_bch`, `pay_ltc`, `pay_dash`, `pay_doge`, `pay_zec`,
`pay_arrr`, `pay_dcr`, `pay_sol`, `pay_eth`, `pay_xrp`,
`pay_usdt`, `pay_usdc`, `pay_dai`
Operator **additions** (your own region-specific methods added via
`morphit-ops payment-method add`) can't be disabled with this
knob — remove them with `morphit-ops payment-method remove`
instead. This knob is for canonical methods only.
> **Interactive menu.** The "Manage payment methods" item in the
> `morphit-ops` main menu (and running `morphit-ops payment-method`
> with no subcommand) opens an interactive list/add/remove menu that
> prompts for each field and reuses the same on-chain add/remove
> paths — no need to remember the flag syntax above.
### Ingest + federation semantics
The indexer rejects a NEW order only when **all** of its payment
methods are disabled (`reason: 'payment_methods_all_disabled'`).
An order that still offers at least one enabled method is accepted
as-is. Like disabled assets, this is OPERATOR-scoped: peer-instance
orders that use a method you disabled still appear in your
read-only orderbook (chain history is shared across the
federation); your users simply can't post orders that offer ONLY
disabled methods, and the picker + orderbook filter hide the
disabled methods.
The parser tolerance is pinned in CI by
`apps/indexer/scripts/disabled-payment-methods-parse-smoke.ts` (12
scenarios), and the ingest gate by two scenarios in
`apps/indexer/scripts/order-handler-smoke.ts` (all-disabled →
reject; one-enabled-remaining → accepted).
## 42. Web Push notifications — VAPID setup and the push-sender worker
**Part 122 cp13.** Morphit's notification system shipped its
in-tab channels (title-bar prefix, favicon canvas badge, PWA
App Badge, OS notifications via the Notification API, audio
cue, vibration cue) across phases 14. Phase 3 — **Web Push**,
which delivers notifications even when the user's Morphit tab
is closed or their phone is locked — landed in cp13.
This section covers the operator-facing pieces: generating the
VAPID keypair, plugging it into your config, what the
push-sender worker does and how to monitor it, and the
privacy/security trade-offs you should be aware of.
### 42.1 What ships
| Component | Location | Purpose |
| --- | --- | --- |
| VAPID keygen | `scripts/generate-vapid-keys.sh` | Generate the operator's keypair once at install time |
| Schema v33 — `push_subscriptions` | `apps/indexer/src/db/schema.sql` | One row per (account, browser) pairing |
| Schema v33 — `push_pending` | same | Durable delivery queue (FIFO drain) |
| Subscribe endpoints | `apps/relay/src/api/push.ts` | `GET /v1/push/vapid-public-key`, `POST /v1/push/subscribe`, `POST /v1/push/unsubscribe` |
| Push-sender worker | `apps/relay/src/policy/pushSender.ts` | Drains `push_pending` every `MORPHIT_RELAY_PUSH_POLL_INTERVAL_MS` (default 2 s) |
| Indexer enqueue | per-handler in `apps/indexer/src/indexer/handlers/` | Each notify-worthy event writes a `push_pending` row |
| Service worker | `apps/web/src/service-worker.ts` | Decrypts pushes and shows OS notifications |
| Client subscribe | `apps/web/src/lib/notifications/push.ts` | `pushManager.subscribe()` + relay registration |
| UI | `NotificationSettings.svelte` | Subscribe / unsubscribe button + privacy radios |
### 42.2 Install — the VAPID keypair (generated by default)
Web Push (RFC 8292) requires the operator to hold a VAPID
keypair. The public half identifies your instance to the
push service so it knows pushes from your relay are
legitimate.
**On by default.** The Ansible playbook generates this keypair
for you ONCE, the first time it runs, and writes it to
`/etc/morphit/relay-vapid.env` — which the relay unit sources
automatically if present. So web push is live out of the box;
you don't have to do anything. The generation is idempotent
(guarded by `creates:`): it is **never** re-run on a later
converge, because rotating the public key would invalidate every
existing subscription on your instance. The VAPID subject
defaults to your instance's public origin (`https://<domain>`);
override it with `morphit_vapid_subject` in `group_vars/all.yml`
(e.g. `mailto:ops@your-domain.example`). Set
`morphit_enable_web_push: false` to skip it.
If you installed by hand, generate the keypair once into the
same file the relay sources, then restart the relay:
```bash
# From the repo root, after `npm install`:
sudo bash scripts/generate-vapid-keys.sh --bare \
--subject https://your-domain.example \
| sudo tee /etc/morphit/relay-vapid.env >/dev/null
sudo chmod 640 /etc/morphit/relay-vapid.env
sudo systemctl restart morphit-relay
```
Run the script with no flags to get a human-readable version you
can paste into any env file instead (it prints the three lines
below with a placeholder subject):
```text
MORPHIT_RELAY_VAPID_PUBLIC_KEY=BH5ZK... # ~88 chars
MORPHIT_RELAY_VAPID_PRIVATE_KEY=AzbhfY... # ~44 chars — TREAT AS SECRET
MORPHIT_RELAY_VAPID_SUBJECT=mailto:operator@your-domain.example
```
The subject MUST be either `mailto:<address>` or `https://<url>`
— it identifies you to the push services (FCM / autopush /
APNS) so they can contact you if your pushes start misbehaving.
If **any** of the three env vars is unset, the relay starts
with push disabled (`/v1/push/vapid-public-key` returns 503,
the client UI shows "Not supported on this device", and users
fall back to the in-tab channels). This is the correct
behavior for operators who don't want to participate in Web
Push.
**Malformed key detection (cp404).** A subtler failure mode is a
VAPID public key that is *set but invalid* — truncated on a
copy-paste, wrapped in quotes, or a stray newline. Previously the
relay would serve whatever string it was given and the browser's
`pushManager.subscribe()` would reject it with an opaque
`applicationServerKey` error on the client, with nothing in the
relay log to point at the cause. The relay now validates the key
at startup: it must be valid base64url decoding to exactly 65
bytes whose first byte is `0x04` (an uncompressed P-256 point).
If it fails that check the relay logs `vapid_public_key_invalid`
and starts with push **disabled** (same 503 behavior as unset),
rather than serving a key that can't work. The env vars are also
trimmed of surrounding whitespace before use. If push is
unexpectedly off after you set the keys, grep the relay log for
`vapid_public_key_invalid` and re-run `generate-vapid-keys.sh`.
### 42.3 Optional tuning knobs
| Variable | Default | What it does |
| --- | --- | --- |
| `MORPHIT_RELAY_PUSH_POLL_INTERVAL_MS` | `2000` | How often the worker drains the queue. Lower = snappier deliveries, more DB load |
| `MORPHIT_RELAY_PUSH_BATCH_SIZE` | `50` | Max queue rows per tick. Caps worst-case latency |
| `MORPHIT_RELAY_PUSH_MAX_AGE_SECONDS` | `3600` | Drop pushes older than this. Stale notifications are worse than no notifications |
| `MORPHIT_RELAY_PUSH_MAX_CONSECUTIVE_FAILURES` | `5` | Delete a subscription after this many consecutive failed pushes (presumed dead browser) |
| `MORPHIT_RELAY_PUSH_REQUIRE_SIGNED` | `true` | When `true` (default, cp14), `/v1/push/subscribe` rejects requests without a valid posting-key signature. Set to `false` only during a brief frontend roll-forward window |
### 42.4 What the push-sender worker actually does
Every `MORPHIT_RELAY_PUSH_POLL_INTERVAL_MS` (default 2 s),
the worker runs one tick:
1. `SELECT … FROM push_pending ORDER BY enqueued_at ASC LIMIT
<batch_size>` — drain the oldest rows.
2. For each row, drop if `event_at` is older than
`MORPHIT_RELAY_PUSH_MAX_AGE_SECONDS` ago.
3. Join against `push_subscriptions` to find every subscribed
device the target account has that has NOT opted out of the
pending row's category (cp450 GAP A — a device's
`muted_categories` blocklist, driven by the per-category
Settings toggle, now governs Web Push just as it already
governed the in-page path; an empty blocklist = all on).
4. For each device, call `webpush.sendNotification()` — the
library signs a VAPID JWT, encrypts the payload per
RFC 8291 (E2E vs the push service), and POSTs to the push
service.
5. On 2xx, mark `last_delivery_at = NOW()` and reset
`consecutive_failures = 0`.
6. On 410 Gone or 404, the subscription is dead — delete it.
7. On transient failures (429, 5xx), increment
`consecutive_failures`. When it crosses
`MORPHIT_RELAY_PUSH_MAX_CONSECUTIVE_FAILURES`, delete the
subscription.
8. Always delete the `push_pending` row after fan-out —
re-trying after a delivery attempt invites duplicates.
Logs are emitted on every non-empty tick under
`relay-push-sender`. Per-device push failures log status
codes only — never endpoint URLs or payload content (privacy
invariant).
### 42.5 Privacy and security model
- **Payload content is end-to-end encrypted.** The web-push
library encrypts every payload per RFC 8291 using the
recipient's p256dh ephemeral public key and an auth secret;
the push service sees ciphertext, not text. An operator
who controls the relay can see what they're enqueuing (the
title and body are stored in `push_pending` before
encryption), but the push service downstream cannot.
- **No subscriber IPs are stored.** The subscribe endpoint
is rate-limited by IP, but the IP never goes into the DB.
- **Subscription endpoint URLs reveal which push service
the user's browser uses** (fcm.googleapis.com = Google;
updates.push.services.mozilla.com = Mozilla;
web.push.apple.com = Apple). This is unavoidable for Web
Push to function. Privacy-preserving users can either
decline push (in-tab channels still work) or use a custom
push server on Firefox via `dom.push.serverURL`.
- **Posting-key signature verification on subscribe (cp14).** As
of Part 122 cp14, `/v1/push/subscribe` requires every request
to carry a valid posting-key signature over the canonical
message
`morphit:push:subscribe:<account>:<sha256(endpoint)>:<timestamp>`.
The signature is verified against the account's posting public
key fetched from the chain. ±5 minute timestamp skew is
accepted. Requests without a signature, or with an invalid
signature, are rejected with HTTP 401.
This closes the cp13 trade-off ("rate-limited-only auth").
The flag `MORPHIT_RELAY_PUSH_REQUIRE_SIGNED=false` exists
for the narrow case where you're rolling a new frontend out
ahead of the relay and want to accept unsigned requests
briefly; in normal operation, leave it `true`.
Multi-key posting authorities are NOT fully supported — only
the first listed key in the posting authority is accepted.
This is documented because every Morphit user account is
single-key in practice; if you operate a multisig posting
authority, push subscribe will fail for you and we'll need
a follow-on checkpoint.
- **End-to-end vs the push service, NOT vs the operator
(DD-2 audit clarification).** Payload encryption per RFC 8291
protects the message body from Google FCM, Mozilla autopush,
and Apple — they see ciphertext, not text. The OPERATOR's
relay, however, sees the localized `title` and `body` strings
pass through the `push_pending` table before encryption.
Everything that ends up in those fields is derived from
PUBLIC chain events (sender names, ratings, order permlinks)
that the operator could already observe by reading the chain;
the queue cache adds no leak beyond chain visibility. Chat
*content* is never in any push payload — the indexer doesn't
hold chat encryption keys.
- **Unsubscribe is signed + rate-limited
(cp131 MED-009 — supersedes the pre-cp131 DD-4 audit
clarification).** `/v1/push/unsubscribe` requires the
same posting-key signature as subscribe over the
canonical message
`morphit:push:unsubscribe:<account>:<sha256(endpoint)>:<timestamp>`,
and the endpoint is per-IP rate-limited (20/hour, same
shape as subscribe). Pre-cp131 reasoning ("if we
required sig-verify on unsubscribe, a user who locked
their session couldn't stop notifications") is preserved
by accepting unsigned unsubscribes in cp13-compat mode
when `MORPHIT_RELAY_PUSH_REQUIRE_SIGNED=false` AND by
the client falling back to unsigned when the session is
locked (the browser-side `PushSubscription.unsubscribe()`
already cuts off future deliveries; the relay-side
delete is best-effort cleanup). ACTION-binding in the
canonical message prevents subscribe↔unsubscribe
signature replay (verified by 5 scenarios in
`apps/relay/scripts/canonical-message-cross-check-smoke.ts`).
Real attack closed: an adversary with a DB-leaked
(account, endpoint) list could pre-cp131 mass-fire
unsubscribes and DoS notifications federation-wide.
- **Captured-signature replay window is bounded but non-zero
(DD-7 audit clarification).** The subscribe signature has
a ±5 minute timestamp skew tolerance. An adversary who
captured a subscribe request from a user could replay it
within 5 minutes. Replay creates a subscription for the
USER'S device (the endpoint is bound to that device by the
push service), so the adversary can't divert push delivery
to themselves. The realized attack is "user unsubscribed
but their device starts receiving notifications again until
they unsubscribe a second time." Nuisance, not security
failure. Mitigation cost (server-side nonce cache for 5
minutes) outweighs the attack value.
### 42.6 Monitoring + troubleshooting
**Single-relay assumption (DD-10 audit clarification).** The
push-sender worker does NOT use `SELECT … FOR UPDATE SKIP
LOCKED` when draining `push_pending`. If two relay processes
ran against the same database — not the current Morphit topology
per ADR-0011 — both workers would SELECT the same rows and
double-deliver. A future HA deployment would need to add row
locking; today's single-relay-per-instance pattern makes this a
non-issue.
**The worker is silent on a quiet queue.** If no events are
enqueued, no logs. The first sign of trouble is usually a
matrix-bot alert that `push_pending` row count is growing
unbounded (set up via the resource-monitor sidecar pattern in
§16.5).
**Common operator-side issues:**
- *Subscribe endpoint returns 503 push_disabled.* Your VAPID
env vars aren't all set. Re-run the keygen and verify each
line is present in your relay's env file.
- *Pushes are delivered but the user reports not seeing
them.* Three checks: (a) the user's browser has notification
permission for your origin (`chrome://settings/content/notifications`,
`about:preferences#privacy` etc); (b) the user has subscribed
on the device that should receive (browsers don't share
subscriptions across devices); (c) the user hasn't muted
notifications via the in-app mute-for / quiet-hours controls.
- *Subscriptions table grows without bound.* The auto-cleanup
on 410 Gone handles browsers that gracefully unsubscribed,
but stale rows can accumulate. Periodically check
`SELECT COUNT(*) FROM push_subscriptions WHERE last_delivery_at < NOW() - interval '90 days'`
and consider pruning manually if the count grows large.
### 42.7 Rotating VAPID keys
Avoid this unless your private key is exposed. Rotating the
public key invalidates every existing subscription on your
instance — users will need to re-subscribe. Procedure:
1. Generate a new keypair via `scripts/generate-vapid-keys.sh`.
2. Post a notice to your community channel (Matrix, etc.):
"Web Push subscriptions will reset on <date>; please re-enable
in Settings if you use push."
3. Update your env file with the new values.
4. Restart the relay.
5. `TRUNCATE push_subscriptions;` — all rows are now bound to
the old public key and won't work.
6. Users re-subscribe via the Settings UI.
## 43. SEO override env vars — homepage title/description/keywords + Twitter card
**Audience:** operators who want to override the bundled homepage SEO copy with
something tailored to their audience without forking the frontend. All fields
are optional — leave any of them unset and the bundled svelte-i18n value (or no
emission, for the Twitter handle) is used.
The frontend reads these via `/v1/instance`, so changes propagate after the
indexer config is re-read (restart `morphit-indexer` after editing the env file).
> **Edit these from the TUI (cp311):** `morphit-ops edit Branding & SEO` now
> edits these three SEO vars **and** the displayed-branding vars
> (`MORPHIT_INSTANCE_NAME`, `_TAGLINE`, `_CONTACT_URL`) in one section. Each
> field is keep-current: press **Enter** to leave it unchanged, type **`-`** to
> clear it, or type a new value (spaces are quoted for you, so a name like
> `Morphit NL` is written safely — unlike a raw hand-edit of a shell-sourced env
> file). This is the supported way to change the bold name on your directory
> card / browser title bar; see the cp311 note under §"Alt-network addresses" for
> how the own-card name propagates.
### Available env vars
**`MORPHIT_INSTANCE_SEO_TITLE`** — overrides the homepage `<title>` and
`<meta property="og:title">`. Max 200 chars. Operators with curated audiences
(e.g. a Persian-speaking community) can swap in language-specific or
audience-specific copy here. When set, the override applies AS-IS — no `— InstanceName`
suffix is appended (you author the full title you want).
**`MORPHIT_INSTANCE_SEO_DESCRIPTION`** — overrides `<meta name="description">`
and `<meta property="og:description">`. Max 500 chars; Google truncates after
about 155 chars in SERPs, so aim for ≤150.
**`MORPHIT_INSTANCE_SEO_KEYWORDS`** — overrides `<meta name="keywords">`.
Most modern crawlers ignore this, but Yandex, Baidu, and some federated
indexers still consume it. Max 500 chars; comma-separated.
**`MORPHIT_INSTANCE_SEO_TWITTER_SITE`** (cp119-A4) — optional X / Twitter handle
for `<meta name="twitter:site">`. When set (e.g. `@morphit`), Twitter cards
include "via @morphit" attribution. When unset, the meta tag is omitted
entirely — the card still renders without it. Format: must start with `@`,
1-15 alphanumeric/underscore chars (Twitter's handle limit). Operators who
don't have or don't want an X presence simply leave this unconfigured.
### Example morphit.config.env block
```env
# Optional: override homepage SEO copy
MORPHIT_INSTANCE_SEO_TITLE="My Instance — privacy-first P2P crypto trading"
MORPHIT_INSTANCE_SEO_DESCRIPTION="Trade BTC, XMR, BLURT and more directly with people in your region. No KYC, non-custodial, federated."
MORPHIT_INSTANCE_SEO_KEYWORDS="p2p crypto, no kyc, bitcoin, monero, federated marketplace"
# Optional: X handle for Twitter Card attribution
MORPHIT_INSTANCE_SEO_TWITTER_SITE="@morphit"
```
Restart the indexer after editing, then verify via:
```bash
curl -sf https://yourinstance.example/v1/instance | jq '.seo'
```
## 44. User-side optional TOTP 2FA — operator-side notes
**TL;DR for operators: zero action required.** 2FA is a purely
client-side, opt-in feature. The Morphit web app offers it as an
option from Settings → Two-factor authentication. The TOTP secret
and backup-code hashes are stored inside the user's encrypted
keystore, alongside their identity. The indexer and relay are not
involved in the 2FA flow at any point.
### What you (the operator) should know
- **The user enrolls — not you.** There is no operator-side
toggle to enable, disable, or require 2FA for users of your
instance. Morphit users own their keys; we don't gate them.
- **There's no server-side state.** The relay holds no TOTP
secrets, no backup codes, no 2FA enabled/disabled flag for
any user. The encrypted keystore lives in the user's browser
storage (or wherever they exported it).
- **Reports of "I lost my 2FA, can you reset it?" — you can't.**
The correct response is: "I can't reset your 2FA because
Morphit doesn't hold your keystore or your secrets. If you
still have your 12-word seed phrase — or, if you signed in with
an existing Blurt account, your Keyfile or your original Blurt
key — you can sign out and re-import to recover. If you saved your 10 backup codes at
enrollment, type one of those at the unlock screen instead
of the 6-digit code. If you don't have either, the keystore
isn't recoverable." This is documented in the user-facing
FAQ (`totp_2fa_lost_authenticator`).
- **No support for "force 2FA before withdrawing $X" or
similar paternalism.** ADR-0043 documents the rejection of
this pattern: gating user funds on a second factor that the
user can lose, on a non-custodial wallet, is contradictory.
- **No telemetry on enrollment.** The relay does not know which
users on your instance have 2FA enabled. No metric is
reported, no log line surfaces the fact. This is intentional.
### Recommending apps to users
Morphit's recommended-apps list ships in
`apps/web/src/lib/auth/recommendedAuthenticatorApps.ts` and is
surfaced to the user at enrollment time. It currently
recommends Aegis (Android), 2FAS (iOS + Android), and Ente Auth
(cross-platform). All three are open source.
The same file also explicitly tells users why Morphit does NOT
recommend Google Authenticator, Microsoft Authenticator, or
Authy. Operators who get pushback on this from a user who
prefers a closed-source authenticator can point them at
ADR-0043 §"Open-source-only recommended-app policy" or the FAQ
entry `totp_2fa_why_not_google_authenticator`. The user is
free to use any TOTP-compatible app — Morphit accepts the
standard otpauth:// URI — but Morphit will only recommend
open-source options.
### If a user reports "the TOTP code never works"
Almost always device clock drift. TOTP requires the user's
device clock to be within 90 seconds of NTP-correct time
(Morphit's TOTP verifier accepts ±1 step on either side of
the current 30-second window, so up to ±90s drift). On
desktops, this is almost never an issue. On phones with
buggy NTP sync, it can be — direct the user to enable
automatic date/time in their system settings.
### Source pointers
- ADR-0043 — design rationale
- `apps/web/src/lib/auth/totp.ts` — RFC 6238 implementation
- `apps/web/src/lib/crypto/keystoreTotp.ts` — unlock-time gate
- `apps/web/src/lib/crypto/keystoreTotpEnroll.ts` — enrollment
- `apps/web/src/routes/[lang]/settings/security/2fa/+page.svelte`
— user-facing UI
## 45. MCP server — AI agent surface
The Morphit MCP server (`apps/mcp-server`) exposes this instance's
federated orderbook to MCP-compatible AI agents — Claude Desktop,
Cursor, Cline, Continue, Windsurf, Zed, and any local LLM stack
built on `@modelcontextprotocol/sdk`. Five read-only tools:
| Tool | What it does |
| :---------------------------- | :----------------------------------------------------------------- |
| `morphit_search_orders` | Search the live P2P orderbook for trades (filter by asset, side, fiat, region). |
| `morphit_list_instances` | Federation directory — the known Morphit instances and their URLs. |
| `morphit_list_payment_methods`| The payment-method slugs the configured instance accepts. |
| `morphit_get_listing` | Fetch one listing in full detail by `(account, permlink)`. |
| `morphit_describe` | A structured, current description of Morphit — what it is, how it works. |
### Why operators are encouraged to enable this
AI agents are becoming the new search layer. When a user asks
their LLM "where can I buy XMR with cash near me," an
MCP-connected agent can answer from your orderbook in real time
and hand them a deeplink to your frontend. Your instance appears
in answers, not just in search-engine results.
Federation-wide effect: every Morphit instance running MCP
enlarges the shared AI-discoverable surface for the project.
Opting out shrinks it.
### Security posture
The MCP server holds **no keys, no privileges, no write paths**.
Tools return public orderbook data (the same data already served
at `/v1/orderbook` etc.) plus deeplinks back to your frontend.
The user's wallet still executes the actual trade — the agent is
strictly a discovery surface.
**Transport.** The service runs the Streamable-HTTP transport
(`MORPHIT_MCP_TRANSPORT=http`, set in the unit) in stateless,
JSON-response mode — no sessions, no long-lived SSE. It binds `127.0.0.1:8124` by default and is **fail-closed**: it accepts
loopback or a private/bridge address — e.g. set
`MORPHIT_MCP_HTTP_HOST=172.18.0.1` in `/etc/morphit/mcp.env` so a
dockerized reverse proxy (BunkerWeb) can reach it across the Docker
bridge, exactly as you do for the indexer/relay listen host — but
refuses `0.0.0.0`/`::` or a public address unless you explicitly set
`MORPHIT_MCP_ALLOW_PUBLIC_BIND=1`. Public exposure is meant to go
through the reverse proxy (below), never a direct public bind. (The
default Host allowlist auto-includes whatever address it binds, so a
bridge bind passes DNS-rebinding protection without extra config.)
**Defenses (all on by default, tunable in `/etc/morphit/mcp.env`).**
DNS-rebinding protection via a Host allowlist (`MORPHIT_MCP_ALLOWED_HOSTS`,
default the loopback Host values), enforced by both our own middleware
and the SDK transport; an Origin allowlist that **rejects any browser
`Origin` by default** (add trusted ones via `MORPHIT_MCP_ALLOWED_ORIGINS`);
a per-client token-bucket rate limit (`MORPHIT_MCP_RATE_LIMIT_PER_MIN`,
default 120/min); a hard request-body cap (`MORPHIT_MCP_MAX_BODY_BYTES`,
default 256 KiB); a connection ceiling (`MORPHIT_MCP_MAX_CONNECTIONS`,
default 64); and slowloris header/request timeouts. Outbound fetches to
the instance API are SSRF-guarded (private hostnames/IPs refused,
body-capped, redirects rejected). The unit adds a seccomp allowlist
(`SystemCallFilter=@system-service`), `ProtectSystem=strict`,
`ReadOnlyPaths`, an empty capability set, `UMask=0077`, and
`MemoryMax=256M`.
**Local agents** (Claude Desktop, Cline, Cursor, …) that spawn the
server themselves use stdio instead — set `MORPHIT_MCP_TRANSPORT=stdio`
(the source default) and point the client's `command` at the server.
### Resource cost
~30 MiB RAM at idle, negligible CPU. The systemd unit
(`ops/systemd/morphit-mcp.service`) caps memory at 256 MiB and
task count at 128 — plenty of headroom for legitimate spikes.
### Setup
**On by default.** The Ansible playbook (the canonical installer)
deploys and enables MCP automatically on every node — you don't have
to do anything. Because MCP is the most exposed surface, it runs
*isolated*: the role creates a low-privilege `morphit-mcp` user and
deploys a self-contained copy of the server to `/opt/morphit-mcp`
(via `ops/scripts/deploy-mcp.sh`, which vendors the two pure,
zero-dependency `@morphit/*` workspace packages so the directory needs
no reach back into `/opt/morphit`). `ProtectSystem=strict` +
`ReadOnlyPaths` then lock it down so the MCP process **cannot read
your DB password or relay keys**. Set `morphit_mcp_enabled: false` in
`group_vars/all.yml` to skip it, or toggle it any time at runtime with
`sudo morphit-ops mcp`.
If you installed by hand instead of via Ansible, create the service
user FIRST (so the deploy chowns the tree to it), then deploy and
enable the unit — order matters:
```
# 1. Isolated service group + user (nologin, home is the deploy dir):
sudo groupadd --system morphit-mcp 2>/dev/null || true
sudo useradd --system --gid morphit-mcp --no-create-home \
--shell /usr/sbin/nologin --home-dir /opt/morphit-mcp morphit-mcp 2>/dev/null || true
# 2. Its directory, owned by the service user:
sudo mkdir -p /opt/morphit-mcp
sudo chown morphit-mcp:morphit-mcp /opt/morphit-mcp
sudo chmod 0750 /opt/morphit-mcp
# 3. Point the MCP at THIS node (else it defaults to morphit.io):
echo "MORPHIT_MCP_INSTANCE_URL=https://YOUR-DOMAIN" | sudo tee /etc/morphit/mcp.env >/dev/null
sudo chmod 0644 /etc/morphit/mcp.env
# 4. Deploy the self-contained tree (vendors deps, npm install, chowns):
sudo bash ops/scripts/deploy-mcp.sh "$PWD" /opt/morphit-mcp morphit-mcp
# 5. Install + enable the unit (it runs the HTTP transport automatically):
sudo cp ops/systemd/morphit-mcp.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now morphit-mcp.service
```
Verify it's listening — the unit sets `MORPHIT_MCP_TRANSPORT=http`, so it
stays up as a loopback HTTP service rather than exiting immediately as a
bare stdio process would:
```
curl http://127.0.0.1:8124/health # → {"status":"ok","transport":"http"}
ss -ltnp | grep 8124 # systemd-owned listener on loopback
morphit-ops health # Services: mcp — running
```
> `morphit-ops upgrade` runs this same `/health` check for you after it
> redeploys and restarts the MCP — but it probes the **configured** bind
> read from `/etc/morphit/mcp.env` (`MORPHIT_MCP_HTTP_HOST`/`_PORT`), not a
> hard-coded loopback. On a dockerized-BunkerWeb host that bind is the Docker
> bridge gateway (`172.18.0.1:8124`), so the probe follows the service wherever
> it actually listens. A failed probe only prints a warning (the MCP is
> isolated, read-only, and non-critical — a miss never rolls back an otherwise
> good upgrade); it tells you to check `journalctl -u morphit-mcp` and confirm
> the bind in `mcp.env`.
The wizard (`morphit-ops init`, step 21) also sets
`MORPHIT_MCP_ADVERTISE=true` in your `morphit.config.env` so the
indexer advertises `mcp_url` for federation discovery.
If you skipped MCP at wizard time and want to enable it later:
```
# Flip the advertise flag in morphit.config.env:
sudo sed -i 's/^MORPHIT_MCP_ADVERTISE=false/MORPHIT_MCP_ADVERTISE=true/' \
/etc/morphit/morphit.config.env
# Start the service:
sudo systemctl enable --now morphit-mcp.service
# Restart the indexer so /v1/instance starts advertising mcp_url:
sudo systemctl restart morphit-indexer.service
```
### Reverse proxy (public exposure)
**If you installed via the Ansible playbook, this is already done for
you** — the canonical dockerized-BunkerWeb stack now proxies `/mcp` to
the host MCP automatically (the `morphit` role binds the MCP so the
BunkerWeb frontend can reach it across the Docker bridge, the `bunkerweb`
role opens UFW for the MCP port to the `bunkerweb_net` CIDR only, and the
frontend nginx carries a `/mcp` location). The playbook also defaults
`morphit_mcp_advertise: true`, so `/v1/instance.mcp_url` advertises the
live `<origin>/mcp` for federation discovery. Set `morphit_mcp_enabled:
false` to skip the MCP, or `morphit_mcp_advertise: false` to run it
without announcing it.
If instead you front Morphit with your **own host nginx** (no BunkerWeb),
the shipped `ops/nginx/web.conf` already includes the equivalent `/mcp`
location (loopback upstream). And if you hand-roll a different proxy, add
a location block like:
```nginx
location /mcp/ {
proxy_pass http://127.0.0.1:8124/;
# Present the loopback Host upstream so the MCP's default
# DNS-rebinding allowlist (127.0.0.1:8124) accepts it. If you'd
# rather forward your public Host, add it to MORPHIT_MCP_ALLOWED_HOSTS
# in /etc/morphit/mcp.env instead.
proxy_set_header Host 127.0.0.1:8124;
# Real client IP for the MCP's per-client rate limiter (it trusts
# the leftmost XFF only because the upstream peer is loopback).
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# MCP responses can be long-running for federation queries.
proxy_read_timeout 120s;
}
```
The indexer's `/v1/instance` response will then include
`mcp_url: "https://<your-origin>/mcp"` (built from
`MORPHIT_INDEXER_PUBLIC_ORIGIN` plus `/mcp`). AI agent operators
discover this via the federation directory and configure their
clients accordingly.
**Dockerized reverse proxy (e.g. BunkerWeb).** If your proxy runs in a
container it can't reach the host's `127.0.0.1`, so — exactly as for the
indexer and relay — bind the MCP to the Docker bridge gateway instead:
set `MORPHIT_MCP_HTTP_HOST=172.18.0.1` in `/etc/morphit/mcp.env`, point
the proxy at `http://172.18.0.1:8124/`, and verify with
`curl http://172.18.0.1:8124/health`. No `MORPHIT_MCP_ALLOW_PUBLIC_BIND`
is needed (the bridge gateway is a private address, not a public bind),
and the default Host allowlist already accepts `172.18.0.1:8124`.
### Disabling
The grandma-friendly switch (priority #3) is the `morphit-ops` menu:
run `morphit-ops` and choose **Check & operate → MCP server
(AI-agent discovery): turn on or off**, or go straight there with:
```
sudo morphit-ops mcp
```
It shows the current service state and offers to enable+start or
stop+disable the unit for you (sudo-aware — bare `systemctl` when run
as root, `sudo systemctl` otherwise), so you never have to remember
the exact unit name. Disabling MCP never affects human traders — only
the AI-agent discovery surface goes dark; re-enable any time by
running it again.
If you prefer the raw systemctl path, there are two switches:
- **Stop advertising** (still serve to local clients):
set `MORPHIT_MCP_ADVERTISE=false` in `morphit.config.env`,
restart `morphit-indexer.service`.
- **Stop the service entirely**:
`sudo systemctl disable --now morphit-mcp.service`
(this is exactly what `morphit-ops mcp` runs for you).
### Source pointers
- `apps/mcp-server/README.md` — protocol overview, tool schemas
- `apps/mcp-server/src/tools/` — implementations
- `ops/systemd/morphit-mcp.service` — hardened systemd unit
- `apps/indexer/src/api/instance.ts` — `/v1/instance.mcp_url` field
- `packages/operator-config/src/index.ts` — `MORPHIT_MCP_ADVERTISE`
allowlist entry
## 46. Resetting the indexer database (schema drift after an upgrade)
**You only need this if `morphit-ops doctor` reports a database-schema
problem, or the indexer logs errors about missing columns/tables right
after an upgrade.** On a healthy node you can ignore this section.
### Why this can happen
Before the 1.0 launch, the database layout lives in a single baseline file
(`apps/indexer/src/db/schema.sql`) that we edit *in place* between versions
rather than shipping a separate numbered migration each time. Your database
records that it already ran that baseline once, so when you upgrade to a
version whose baseline gained a new table or column, the indexer does **not**
re-run the baseline on your existing database — and the newer code can end up
expecting structures the database doesn't have.
Two things now tell you when this is the case:
- **`morphit-ops upgrade`** prints a one-line reminder at the end when the
upgrade you just applied crossed a schema change.
- **`morphit-ops doctor`** runs a read-only check and reports
`Database schema (drift detected)` with the exact tables/columns that are
missing. (Pass `--no-db` to skip this check, e.g. when Postgres is down.)
### Why it's safe to fix by resetting
The indexer database is a **derived cache**, not a system of record. Every
order, feedback entry, and operator action lives on the Blurt chain; the
indexer just reads the chain and rebuilds its tables from it. Dropping and
recreating the database loses nothing permanent — the indexer re-derives
everything on its next sync. Your relay account and signing key are stored
separately and are **not** touched by any of this.
### The reset
1. **Stop the indexer:**
```
sudo systemctl stop morphit-indexer
```
2. **Drop and recreate its database.** Use the database name + owner from
your `MORPHIT_INDEXER_DATABASE_URL` (in `morphit.env`). If your URL is
`postgresql://morphit_indexer:…@localhost/morphit_indexer`, the database is
`morphit_indexer` and the owner is `morphit_indexer`:
```
sudo -u postgres psql -c "DROP DATABASE morphit_indexer;" \
-c "CREATE DATABASE morphit_indexer OWNER morphit_indexer;"
```
(Substitute your own names. This is the same drop-and-recreate you'd do to
switch chains — see §25.)
3. **Start the indexer again:**
```
sudo systemctl start morphit-indexer
```
On boot it creates the current schema from scratch, then begins syncing
from the chain. A fresh indexer reports `degraded` until it catches up —
that's normal.
4. **(Optional) Skip the long re-sync.** A full re-sync from genesis can take
a while. To jump the cursor close to the chain head instead:
```
morphit-ops fast-forward
```
(You can pass a specific block number — `morphit-ops fast-forward BLOCK` —
but with no argument it advances to a recent block for you.)
5. **Confirm:**
```
morphit-ops doctor
```
You want `Database schema (matches this version)`.
## 47. Keeping the relay funded — low-BLURT notifications
The relay creates each account with a direct `account_create` op, paying
the chain `account_creation_fee` (≈100 BLURT) **inline** from its liquid
BLURT (Blurt disabled the Account-Creation-Token model at hard fork 2 —
ADR-0010, as amended 2026-06). So signup readiness gates on one thing: the
relay's **liquid BLURT balance**. When it can't cover the fee plus a small
margin, signups fail with `relay_out_of_funds` until you top it up;
recovery is automatic on the next health poll (~30s). There is nothing to
"mint" and no buffer to maintain — keep `@morphit-relay` funded (see §0a
for the math) and let it tell you, over Matrix, when to add more.
### 47.1 Get notified when BLURT runs low (so you can top up)
Two complementary signals, both delivered to your Matrix alert DM by the
matrix-bot (which reads the indexer/relay JSON journal). Set
`MORPHIT_MATRIX_BOT_ALERT_MXID=@you:your.server` for the bot and run it.
1. **On-chain balance threshold (indexer).** Opt-in; default off. In the
indexer env:
```
MORPHIT_INDEXER_OPERATOR_BALANCE_INTERVAL_MS=300000
MORPHIT_INDEXER_OPERATOR_BALANCE_RELAY_THRESHOLD_BLURT=300
```
The indexer watches `@morphit-relay`'s liquid balance and fires
`operator-balance:low_balance` → Matrix when it drops below the
threshold (CRITICAL at zero — the relay has halted; WARN above zero).
Hysteresis means one alert per downward crossing, plus a
`balance_recovered` when your top-up lands. **Set the threshold above
roughly one week of signup fees** so the warning arrives with real lead
time — well into the thousands of BLURT for a busy instance; 300 suits a
quiet one. (See also §16.)
2. **Signups actually down (relay, CRITICAL).** Automatic, no config: the
relay's health poller watches its own liquid balance; the moment it
drops below the account-creation fee + margin — meaning signups are
being REFUSED with `relay_out_of_funds` right now — it emits
`relay-acts:relay_low_balance_for_signups` (CRITICAL → Matrix), naming
the account, the balance, and the BLURT required.
To top up: transfer liquid BLURT to `@morphit-relay`. Signups recover on
their own within ~30 seconds — no command to run.
## 48. IPFS release hosting — every instance pins the signed release
Every Morphit instance runs a small **IPFS (Kubo) node** that pins THIS
instance's current signed release. The point is decentralization
(priority #2): if the code lived only on a handful of pinning services
(Pinata, Storacha, …) and they all dropped it, the IPFS copy would
vanish. With every operator pinning, the signed release is served from as
many independent nodes as there are instances — and operators keep 90% of
the BLURT listing fees, so hosting the release they run is the quid pro
quo. **This is ON by default.** (The code never disappears regardless —
it is also on nine git mirrors and anchored on-chain — but this keeps the
*IPFS* copy alive without depending on any single provider.)
### How it works
1. The instance's own indexer already reads `morphit_release_v1` from the
chain, so `/v1/release` now carries the release's `distribution` block,
including its `ipfs_cid` (a **directory CID** — the tarball, notes, and
`metadata.json`). No third party is trusted for the CID.
2. `morphit-ipfs-pin.sh` (on a timer + at boot) reads `ipfs_cid` from
`/v1/release` and runs `ipfs pin add` **by CID** — it fetches and keeps
the exact bytes the chain anchors, so the node serves the same CID
everyone else does. Only the release maintainer ever *publishes* the
IPNS name; instances just *provide* the content it resolves to.
3. The node is deliberately light (priority #4): the Kubo **`lowpower`
profile**, a small connection cap, loopback-only API/gateway, and
periodic GC. The only content it keeps is the ~12 MB release directory.
### Setup
**Ansible installs get it automatically** — the `ipfs` role runs by
default (`enable_ipfs: true` in `group_vars/all.yml`). It installs Kubo
(pinned `morphit_kubo_version`, checksum-verified), initialises the repo,
and enables the daemon + the `morphit-ipfs-pin.timer`. To pin Kubo's exact
bytes, set `morphit_kubo_sha512`. To opt a host out, set `enable_ipfs:
false` for it in inventory.
**Hand-managed installs** (no Ansible): run the shipped setup once, or pick
it from `morphit-ops harden` → "Set up IPFS release hosting":
```sh
sudo sh ops/ipfs/morphit-ipfs-setup.sh
```
It installs Kubo + the pinning service/timer idempotently. Then:
```sh
systemctl status ipfs morphit-ipfs-pin.timer
sudo systemctl start morphit-ipfs-pin.service # pin right now
journalctl -u morphit-ipfs-pin -e --no-pager
```
### Verifying + footprint
`ipfs pin ls --type=recursive` on the box lists the pinned release CID; it
should match `distribution.ipfs_cid` from `/v1/release` (and the on-chain
anchor). Resource use is modest — a low-power DHT participant plus a few MB
of pinned content — but it is a running daemon; a memory- or bandwidth-
constrained box can opt out as above. The pinning service is non-fatal by
design: a Kubo outage or a slow fetch never affects the site.
## 49. Advanced install paths — Ansible playbook or build-from-source
The friendly one-command install (`sudo bash morphit-setup.sh` → "Full guided install") is the recommended path for everyone and is documented in `RUN-A-MORPHIT-NODE.md`. This section is for operators who would rather drive the install by hand: run the Ansible playbook themselves, or build from source and install the prerequisites manually. Both reach the same hardened end state; neither is a "lighter" install.
### 49a. Run the Ansible playbook yourself
Morphit ships an **Ansible playbook** that does the whole operating-system setup on a fresh Ubuntu box — it installs Node.js and PostgreSQL, builds the app, lays down the background services, and deploys the website and the read-only helper. You fill in a couple of small config files and run one command:
```sh
cd ops/ansible
# 1. Tell it which machine(s) to set up.
cp inventory/hosts.yml.example inventory/hosts.yml
$EDITOR inventory/hosts.yml
# 2. Fill in the non-secret settings (your domain, your Blurt account, etc.).
$EDITOR group_vars/all.yml
# 3. Put your secrets in an encrypted vault.
cp group_vars/vault.yml.example group_vars/vault.yml
$EDITOR group_vars/vault.yml
ansible-vault encrypt group_vars/vault.yml
# 4. Run it.
ansible-playbook -i inventory/hosts.yml playbook.yml --ask-vault-pass
```
When it finishes, **everything is installed and running** — the app and background services, HTTPS with automatic renewal, the BunkerWeb firewall, your Tor `.onion` and I2P addresses, and full server hardening. There is nothing more to switch on; go straight to registering as an operator (`RUN-A-MORPHIT-NODE.md` §9). TLS is already obtained + auto-renewing (§35) and BunkerWeb is already on (§32).
### 49b. Configure only — you install the prerequisites (build from source)
If you would rather install the prerequisites yourself and just have `morphit-ops` write your configuration, this is the path. (It is the **"Configure only"** choice in the installer.)
Install **Node.js 22**, **PostgreSQL 15.x or higher**, and **nginx** from your system's package manager (`psql --version` should read 15.x or higher). Then get Morphit and build it from source:
```sh
cd ~
git clone https://git.agorise.net/agorise/morphit.git
cd morphit
npm install
npm run build --workspaces --if-present
```
`npm install` pulls in the libraries (a few hundred MB — normal) and creates the `morphit-ops` command you use for everything else. It also wires up Morphit's internal **workspace symlinks** (`@morphit/asset-registry` and friends). If you ever run the test suite before `npm install` finishes and see `ERR_MODULE_NOT_FOUND` complaining about `@morphit/asset-registry`, that just means the symlinks are not in place yet — run `npm install` and it clears up. **Re-run `npm install` after every `git pull`.**
Now run the installer and choose **"Configure only"** when it asks:
```sh
npx morphit-ops install
```
It checks your prerequisites (Node 22, PostgreSQL, git), runs the **setup wizard**, and offers to harden the server. On this path it deliberately does **not** install Node/PostgreSQL or the background services for you — that is what the guided install (or the playbook in §49a) is for — so set up the database and services next.
**Database.** Pick a strong password (`openssl rand -base64 32`), save it, then create the role and database:
```sh
MORPHIT_INDEXER_DB_PASSWORD='<your-strong-password>' \
sudo -E -u postgres psql -f ops/postgres/init.sql
cd apps/indexer && npm run migrate && cd ../..
```
The init script refuses to run with a placeholder like `__SET_BEFORE_DEPLOY__` or `CHANGEME` — that is on purpose, so nobody ships with an example password. (Full sentinel details: §30.)
**Background services.** The shipped unit files assume the default `/opt/morphit` path, so instead of editing them by hand, run the **path-aware installer** — it detects where you actually cloned the repo and writes the services with the correct paths:
```sh
sudo bash ops/scripts/install-systemd-units.sh
# The units run as root and source these env files; keep them
# root-owned, group-readable by the morphit service group (0640):
sudo chown root:morphit /etc/morphit/indexer.env /etc/morphit/relay.env
sudo chmod 0640 /etc/morphit/indexer.env /etc/morphit/relay.env
sudo systemctl enable --now morphit-indexer morphit-relay
```
**nginx.** Serve the built website over HTTPS and proxy the API to the local services. The shipped `ops/nginx/web.conf` is a complete, ready-to-adapt server block — copy it and change `yourdomain.com` to your domain. Keep its security headers and no-cache rules byte-for-byte (§15 for the headers; "Caching the update surface" / §14 for the no-cache blocks and the header-inheritance caveat). One thing worth setting while you are in there: the live-chat endpoints (`/v1/chat/…/stream` and `/v1/chat-activity`) are held-open "streaming" connections, so instead of the usual per-minute request limit they want a **per-visitor cap on how many streams one address can hold open at once** (a generous number — a few dozen). The exact `limit_conn` snippet is in the BunkerWeb / reverse-proxy section (§32).
Then turn on HTTPS with a free Let's Encrypt certificate — `npx morphit-ops ssl setup` prints the exact `certbot` line for your domain (§35) — and register as an operator (`RUN-A-MORPHIT-NODE.md` §9).
## 50. How your indexer treats the public Blurt RPC nodes (User-Agent + rate limits)
The public Blurt RPC nodes are run by volunteers, for free, and Morphit leans on them. Your indexer is built not to abuse that: it caps its own request rate, backs off exponentially when a node pushes back, jitters its retries so every Morphit instance in the federation does not stampede the same node at the same second, and fetches blocks in batches of 20 rather than one request at a time when it is catching up after downtime. If a node's firewall refuses those batches (some public nodes return an HTTP 406/403 to a batched request while serving single ones fine), your indexer notices and quietly switches to one-at-a-time for that node — so a single strict node cannot stall your catch-up, and you never have to hand-pick endpoints (since v1.8.1). (Endpoint selection itself: §22.)
It also **says who it is**. Every request your indexer makes to a Blurt RPC node carries:
```
User-Agent: Morphit/<your indexer version> (+https://git.agorise.net/agorise/morphit)
```
(`<your indexer version>` is the same version `/v1/health` reports — so an operator can tell an old instance from a current one.) Node's built-in `fetch` sends `user-agent: node` by default — the same string as every anonymous script on the internet, exactly what bot-detection rules are written to catch — and it gives an RPC operator nobody to contact if your traffic misbehaves. Naming ourselves means an operator who wants us to back off can find us instead of just blocking us. Two of Morphit's own background jobs identify themselves more precisely still — `morphit-indexer/federation-probe` and `morphit-indexer/signup-anomaly-probe` — because a node operator reading logs is better served by "which job" than by "which app".
**Be aware of what this means for your server.** The header goes on *every* outbound request your indexer makes — the Blurt RPC nodes, the BLURT price feed, and the federation probe that checks other instances. So any host your indexer contacts learns that the IP calling it is running Morphit, and which version. Your server's IP is visible to those hosts either way, and the public instances list already names the instances that want naming; but if you are running an instance you would rather nobody enumerate, know that this header is one of the ways they could. It says nothing whatsoever about your *users* — their browsers are not touched by this; their requests go to your indexer, not through it. You do not configure any of this and there is nothing to tune.