catcrafts.net/deploy/README.md
Jorijn van der Graaf 33c68c2f44
All checks were successful
Deploy / build-deploy (push) Successful in 1m48s
financial page with bank data
2026-08-14 04:14:13 +02:00

38 KiB
Raw Blame History

Deploying catcrafts.net

Two artifacts come out of CI and land in two different places, on purpose.

goes to served by
wasm bundle + static assets /srv/catcrafts.net Caddy file_server
catcrafts-server + content/ /srv/catcrafts-app itself, on 127.0.0.1:8081

They are kept apart because the web root is publicly served and mirrored with rsync --delete on every push. Anything runtime-owned that lives there is both published to the internet and destroyed on the next deploy — which matters now for the content files and matters a great deal once the shop has a database and bank credentials.

Runtime state goes in a third place, /var/lib/catcrafts, created by the service's StateDirectory=. Today that is orders.jsonl (the order event log), orders.jsonl.shipping.json (the cached carrier rate table) and three files behind the public /financials page — orders.jsonl.financials.json (the published running totals), orders.jsonl.financials-seen.json (ingested bunq mutation ids, so a redelivered callback cannot double-count) and orders.jsonl.financial-rules.json (the classifier). See "The open financials page and the bunq mutation callback". Neither payment provider needs stored state — both authenticate with a bearer token per request.

Two things on this box cannot be regenerated. Everything else — the wasm bundle, the content, the binary — comes back from a rebuild.

The first is orders.jsonl. It is the ledger: every order and every status transition, append-only, and the audit trail the tax records lean on. Back it up:

install -d -m 0700 /var/backups/catcrafts
cp /var/lib/catcrafts/orders.jsonl \
   /var/backups/catcrafts/orders-$(date -u +%F).jsonl

It contains names, addresses and email addresses, so it is personal data: keep it 0600, keep it off the web root, and encrypt it before it leaves the machine. (The cached shipping table is deliberately NOT worth backing up: delete it and the next Sendcloud refresh rebuilds it. Neither are the financials files: the weekly reconciliation regenerates the totals from the bank history, and the rules file is a handful of lines you can rewrite.)

The second is /srv/catcrafts-app/media — the mirrored post images and screen recordings. Usually reproducible from content/posts.json, but not if a source instance has deleted the file since, and for these posts the media is the content. It lives on the app mount rather than the web root specifically so rsync --delete cannot reach it, and CI only ever adds to it. Worth including in the same backup.

Why the backend speaks plaintext HTTP/1.1

Caddy terminates TLS and reverse-proxies to loopback. The backend uses Crafter.Network's ListenerHTTP1 rather than its HTTP/3 ListenerHTTP for one concrete reason: Caddy cannot reverse_proxy to an h3 upstream. Port 8081 must never be exposed directly.

One-time host setup

# 1. service account, no login, no home
useradd --system --no-create-home --shell /usr/sbin/nologin catcrafts

# 2. directories
mkdir -p /srv/catcrafts.net /srv/catcrafts-app
chown catcrafts:catcrafts /srv/catcrafts-app
# The web root stays writable by whatever uid the runner container uses.

# 3. units
cp deploy/catcrafts-server.service /etc/systemd/system/
cp deploy/catcrafts-deploy.service /etc/systemd/system/
cp deploy/catcrafts-deploy.path    /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now catcrafts-deploy.path
# catcrafts-server is started by the first deploy; enable it so it survives a
# reboot:
systemctl enable catcrafts-server

# 4. Caddy — merge deploy/Caddyfile.example into your site config
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy

Runner configuration

Both mounts are required. In the Forgejo runner's config.yaml:

container:
  options: "-v /srv/catcrafts.net:/deploy -v /srv/catcrafts-app:/deploy-app"

The deploy steps fail with an explanatory message if either is missing, rather than silently succeeding into the container's own filesystem.

How the restart happens

CI runs inside a container and has no access to the host's systemd. Rather than give the runner host root or a polkit rule — both of which grant far more authority than "restart one service" needs — the last deploy step writes /srv/catcrafts-app/.deploy-stamp, and catcrafts-deploy.path on the host reacts.

catcrafts-deploy.service runs catcrafts-server --selftest as ExecStartPre before restarting. That self-test exercises the HTML-escaping and JSON layers that generate every byte of markup the site emits, so a broken binary leaves the working server running instead of replacing it.

The binary is installed as catcrafts-server.new and mvd into place, so a request arriving mid-copy never hits a truncated executable.

If the backend is down

Caddy's handle_errors fallback serves the static index.html, so the site degrades to the client-rendered wasm app rather than showing a 502. Content still renders; what is lost is server-side rendering and real status codes — an unknown path becomes a soft 404 again until the backend returns.

Posts and their media

content/posts-sources.json holds the account and a community allowlist. Only listed communities are fetched, so joining a new one does not silently publish it to the site — add a line first.

tools/publish-media.sh FILE  # BEFORE posting: uploads to /media, prints the URL
tools/fetch-posts.sh         # writes content/posts.json
tools/fetch-media.sh         # mirrors the media, rewrites posts.json to /media/... paths

Order matters: the second reads what the first wrote. CI runs both before the build. Neither fails the build on a network error — a fediverse outage leaves the previous posts.json in place, and a single failed download leaves that one entry pointing at its original URL rather than losing the post.

The full body, and its inline media

Each post is hosted whole at /posts/<slug>, with the card on /posts linking to it — the writing is what the site is about, and a page this site can name as canonical is the only version a search engine can be pointed at. The comments are not mirrored: every post page links out to the thread, which is where the discussion belongs.

The body stays Markdown in posts.json and is rendered by Catcrafts.Shared:Markdown at page-render time, never converted to HTML by the shell. That is deliberate: the renderer is inside the escaping guarantee, and text fetched from someone else's server must not be able to become markup anywhere else. Raw HTML in a body is always shown as text.

fetch-media.sh mirrors what the body embeds as well as the headline file, and rewrites the URLs inside the Markdown, so a post page loads nothing third-party either. It also writes a body_media list per post — dimensions, poster frame and format renditions for each inline file, which Markdown syntax has nowhere to carry. Slugs come from the title; a duplicate title takes the post's numeric id as a suffix, so an old post's URL is never renumbered by a new one. Re-running the script is a no-op: local paths are adopted from the mount rather than re-fetched.

The image format ladder

Every mirrored still image is transcoded to two siblings named after its content hash, and Catcrafts.Shared:Media serves all three as one <picture> — so the browser fetches exactly one:

tier file size vs. WebP who gets it
<source type="image/avif"> <hash>.avif 76% almost everyone
<source type="image/webp"> <hash>.webp (the mirrored original) 100% Safari 1416
<img src> <hash>.png 875% neither of the above

The middle tier is why the PNG being ~9× the WebP does not matter: it is free (the mirror already downloaded that file) and it is what the small number of non-AVIF browsers actually land on. The PNG is the floor nothing can refuse.

AVIF is encoded at crf 26, cpu-used 6, yuv444p — measured at SSIM 0.997 against the source and still smaller than it. Full chroma is deliberate: these are screenshots of text, and re-subsampling chroma that pict-rs already subsampled once fringes coloured text visibly, for about 3% more bytes.

Encoding is skipped when the sibling is already on the mount, so only genuinely new images cost encoder time (~0.5 s each). Animated sources are left alone entirely — one moving GIF beats three copies of its first frame. Video posters are skipped too: poster takes exactly one URL, so a <video> cannot negotiate a format the way <picture> can and the renditions would be unreachable.

Both encodes pin -c:v and then verify the codec that actually came out. That check earned its place immediately: -f image2 out.png without an explicit codec makes ffmpeg fall back to the muxer default, which is MJPEG — it silently produced a full set of lossy JPEGs under .png names, served to browsers as image/png. A rendition that fails the check is discarded and its tier dropped.

Publish the media first, then post it

The recommended flow is to put a recording on catcrafts.net before writing the post, and use that URL as the post's link. Run tools/publish-media.sh recording.mp4; it transcodes to AV1 and an H.264 sibling, uploads both to the media mount under the AV1's content hash, and prints a https://catcrafts.net/media/<hash>.h264.mp4 URL to paste into the post. The H.264 one on purpose: a post's link is fetched raw — Lemmy apps and browsers play that exact file, with no negotiation in front of it — so it must be the encoding everything can play. (An AV1 link posted before this existed drew "bad media error" reports from iPhones within hours.)

fetch-media.sh recognises its own origin and adopts such a URL: it rewrites it to /media/<hash> and probes the local file for dimensions, downloading nothing. That is not just an optimisation — it removes the whole class of build failure where the media step depends on a third party. A 167 MB recording on a file host once hit MAX_BYTES (64 MB), so the entry kept its original URL, and the deploy then failed the e2e "media origin" check on a file that was sitting on our own disk the entire time.

Adopting a <hash>.h264.mp4 URL swaps the AV1 sibling back in as the page's primary <source> when it is on the mount, keeping the H.264 as the fallback <source>. So the post links the compatible file, while browsers that can take AV1 download the small one — the codecs parameter on the first source is what lets the rest skip it.

The transcode matters as much as the hosting. Phone recordings are wildly oversized for what they show — that same 167 MB clip was 78 s of a dark room at 17 Mbps, and denoising into AV1 gives the same picture in 15 MB. It also bakes in the rotation: phones record landscape and attach a display matrix, so an untouched file reports 1920x1080 while playing portrait, and the width/height attributes then reserve exactly the wrong box. (fetch-media.sh swaps the dimensions when it sees a quarter-turn matrix, so a straight-from-phone mirror is correct too — but transcoding means nothing downstream has to know.)

Two things to know about publishing AV1:

  • Browsers without AV1 (Safari before 17, Apple hardware older than A17/M3) get the <hash>.h264.mp4 sibling: on the site via the second <source>, on the fediverse because that sibling is the posted URL. --raw skips the transcode and the fallback both, so a raw AV1 upload recreates the will-not-play problem — use it for files that are already universally playable.
  • Lemmy's pict-rs will not generate a thumbnail from an AV1 file, so a self-hosted video usually arrives with no poster. publish-media.sh uploads a poster frame beside the video, named <video-hash>.poster.webp, and fetch-media.sh falls back to that sibling when the instance supplied nothing. A thumbnail the instance did provide always wins. (Posting the H.264 URL also means pict-rs can thumbnail it again, so instance thumbnails come back.)

An own-origin URL naming a file that is not on the mount is deliberately left pointing at its original URL rather than rewritten. That is a post published without its media, and failing the e2e origin check loudly beats shipping a 404 inside a <video> tag.

fetch-media.sh wants ffprobe (Arch: ffmpeg) to read pixel dimensions, which become the width/height attributes that stop the page reflowing as several 5 MB recordings arrive. It degrades to no dimensions without it — so tools/e2e.sh asserts they are present, and a build host missing the package fails at the e2e gate instead of quietly shipping a janky page.

Only the post's main media is used. Lemmy distinguishes the media a post is (post.url, with post.thumbnail_url as its generated still) from images merely embedded in the body. Only the former is mirrored — a post whose pictures are body-only renders as text, which is correct. Videos get the thumbnail as their poster; without it a preload="metadata" video is a black box until someone presses play.

Thread links point at the community's instance, not the author's. A post's ap_id is on the account's own instance, but the discussion is in the community, which is federated elsewhere and has its own local id for the same thread. So fetch-posts.sh resolves each ap_id through the community instance's resolve_object and stores that URL. One request per post, at build time; a failure falls back to the ap_id, which still reaches a readable copy.

Media is mirrored, not hotlinked. Three reasons: the privacy notice says everything the browser loads comes from catcrafts.net and should stay true; hotlinking would send every visitor's IP to the source instance; and these posts are their media, so a deleted upstream file would gut the page. Filenames are the content hash, which is why the cache lifetime can be a year.

Payments: Mollie and CoinGate

Checkout offers the buyer two choices, each served by its own rail:

Choice Rail Env var What the buyer gets
bank Mollie MOLLIE_API_KEY iDEAL, cards, bank transfer
crypto CoinGate COINGATE_API_KEY Bitcoin + Lightning, stablecoins, more

The slots are independent. Set one key and the form offers only that method; set both and the buyer picks; set neither and checkout answers 503 with an honest message — the whole site still works, degraded rather than down. A mode whose key is missing is a startup refusal, not a silent downgrade: a checkout that 502s at the last step is worse than one that never offered.

# Mollie keys: dashboard -> Developers -> API keys. CoinGate: dashboard ->
# API -> new app token. BOTH have real test modes that work against the real
# endpoints — verify the whole flow before swapping in production keys.
install -d -m 0755 /etc/catcrafts
cat > /etc/catcrafts/payments.env <<'ENV'
MOLLIE_API_KEY=test_your-key-here
COINGATE_API_KEY=your-coingate-token
COINGATE_SANDBOX=1
ENV
chmod 0600 /etc/catcrafts/payments.env
systemctl restart catcrafts-server
journalctl -u catcrafts-server | tail  # "payments: bank=mollie crypto=coingate"

Drop COINGATE_SANDBOX=1 for live crypto payments; it selects api-sandbox.coingate.com and its tokens are not interchangeable with live ones.

Mechanics worth knowing:

  • The reconciler polls each open order against the rail that issued its link — the ledger records pay_choice per order for exactly this reason. Mollie every 10 s, CoinGate every 20 s (a blockchain confirmation will not arrive faster), both backing off with age. The ?redirect back from either provider is ignored by design: only the authenticated poll moves an order to paid, and the paid event records the method (ideal, creditcard, btc, …) in the ledger.
  • Both providers EXPIRE unpaid orders, which lapses them automatically and the buyer just orders again. CoinGate is far more aggressive about it: two hours before a coin is picked, twenty minutes after. Expect crypto orders to lapse routinely; that is the normal case, not a fault.
  • CoinGate settles in EUR (receive_currency=EUR in the rail), so the money that lands is the money the invoice says, the rate is locked when the buyer opens the invoice, and no crypto touches the balance sheet. That one parameter is the whole difference between "a second Mollie" and "the shop now holds crypto" — changing it is a tax decision, not a code cleanup.
  • Card money stays disputable for months even after "paid": before shipping a large or exported order, glance at the via column in --orders. iDEAL, bank transfers and crypto are final; creditcard is the one with a tail. This is the one real advantage of the crypto rail — no chargebacks — and it matters most on exactly the non-EU orders where cards get declined.
  • A refunded CoinGate order that the reconciler sees while still awaiting gets lapsed and logged loudly: it means a long outage spanned the entire paid window and the money has since gone back. That is the case where --mark-paid may be the right answer and only a human can tell.
  • Mollie onboarding reviews the shop: the imprint (KVK, contact address), terms and privacy pages must be real before they approve live payments. They are — and e2e now fails the build if a PLACEHOLDER marker ever reaches a rendered page again. CoinGate onboarding is a KYB review of the registered business (KVK, UBO, bank account) and wants the same pages.
  • Accepting crypto for goods does not make this shop a CASP under MiCA — no custody, no transfer for third parties, so no licence is required. What it does require is that the processor holds one: since 1 July 2026 only MiCA-authorised CASPs may serve EU clients, and CoinGate holds both a MiCA licence and a Payment Institution licence. Verify any replacement provider in the ESMA register before switching a key.
  • VAT is unchanged by payment method: the sale is priced and invoiced in euro and taxed on the euro value, whichever rail settled it.

Shipping rates: Sendcloud (REQUIRED to sell)

Sendcloud is the only source of shipping prices. There is no compiled-in fallback table: a country the carrier has no rate for is a country the shop cannot post a parcel to, so checkout refuses it rather than quoting a price that would then have to be refunded or absorbed. The consequence is blunt and intended — with no rate table, every checkout refuses, and the server says so at startup:

shipping: NO RATE TABLE — checkout will refuse every order until Sendcloud answers
# credentials from Sendcloud: Settings -> Integrations -> API
cat >> /etc/catcrafts/payments.env <<'ENV'
SENDCLOUD_PUBLIC_KEY=...
SENDCLOUD_SECRET_KEY=...
SENDCLOUD_METHOD='PostNL Parcels non-EU,DPD Home'
ENV
systemctl restart catcrafts-server
journalctl -u catcrafts-server | grep shipping:   # "table refreshed (N countries...)"

SENDCLOUD_METHOD is a comma-separated list of name substrings, merged in order with the FIRST FILTER to cover a country winning it — put the postal method first so non-EU destinations get post rates (a courier method that also covers Norway or Switzerland would otherwise price them at courier rates, €54 instead of €19), and the courier second to fill the EU.

Every method matching a filter is kept, not just the first: Sendcloud lists the same service once per weight band, so the matches for DPD Home are that service's ladder. A parcel is priced at the cheapest band that can carry it, where the weight is the product's boxed unit weight (shipWeightGrams in Catcrafts.Shared-Content.cppm) times the quantity ordered. That also sets the quantity ceiling: one order is one parcel, so an order heavier than every band is refused with the number that would fit, and the buy form's max shows the best case across destinations.

A method that publishes no max_weight is skipped rather than treated as unlimited — same principle, no invented numbers.

The cache is the resilience layer

The fetched table is cached next to the orders file (<orders>.shipping.json) and read at startup whether or not credentials are configured, so a Sendcloud outage keeps selling at the last known prices. Format is country -> [[maxWeightGrams, consumerCents], …], prices already VAT-inclusive (EU rates are grossed up once, at fetch, so the shop nets the carrier's cost).

That also means a hand-written cache file is a complete rate table, which is how dev and tools/e2e.sh run with no Sendcloud account at all. A cache written by an older build (flat country -> cents, no weight bands) is ignored on load and replaced by the next refresh — those numbers were an unknown weight band and re-serving them would price parcels by guess.

Like the CoinGate rail, this integration is UNTESTED against the live API until credentials exist — the response parser is covered by --selftest, the fetch around it is thin. Verify one real fetch before opening the shop: check that the logged country count and the weight bands match what the Sendcloud panel shows, because that table is now the difference between a shop that sells and one that refuses everything.

The buyer sees whatever the server will charge: the checkout page embeds the active table into its live total (picking the same band, refusing in the same places), and the amount is computed server-side at order time from the same data.

Invoice signing (GPG)

Paid orders offer a clearsigned markdown invoice at /order/<token>/invoice.md. Numbering continues the pre-shop administration: one series per customer — a random UUID as the customer number, invoices counting sequentially within it (f57c6512-…-3), keyed by the buyer's email. Art. 226(2) permits "one or more series"; completeness is provable by reconciling the append-only ledger against the payment provider's records. The signature makes the invoice verifiable forever, independent of this server — which is why the order page tells buyers to download it rather than promising to host receipts indefinitely.

One-time key setup on the server, as the service user:

sudo -u catcrafts env GNUPGHOME=/var/lib/catcrafts/gnupg \
  gpg --batch --passphrase '' --quick-gen-key 'Catcrafts invoices <invoices@catcrafts.net>' default default never
# export the PUBLIC key and commit it to the repo so buyers can verify:
sudo -u catcrafts env GNUPGHOME=/var/lib/catcrafts/gnupg \
  gpg --armor --export invoices@catcrafts.net > invoice-key.asc

Then in /etc/catcrafts/payments.env:

INVOICE_GPG_KEY=invoices@catcrafts.net

and Environment=GNUPGHOME=/var/lib/catcrafts/gnupg in the service unit (see catcrafts-server.service). The key has no passphrase because the service signs unattended; the keyring lives in the 0700 StateDirectory. With a key configured, a signing failure is a 500 — an unsigned invoice is never served by accident. Without one (dev), invoices carry a visible UNSIGNED marker.

Order email (confirmation + invoice)

A paid order gets one confirmation email with the clearsigned invoice attached — plain text plus a markdown attachment, no HTML part, no remote resources, nothing the privacy notice would have to explain. The mailer watches the ledger, so every path to paid (reconciler, arrival poll, a manual --mark-paid even on a later restart) results in exactly one email: the notified event, appended only after the mail command accepts the message, is what stops a resend.

Delivery shells out to a sendmail-compatible command rather than speaking SMTP itself, for the same reason invoices shell out to gpg: TLS, AUTH and deliverability are exactly what msmtp already does well, and the volume is a handful of messages per week. Deliverability stays the mailbox provider's problem (SPF/DKIM are theirs), and no third party beyond the provider that already handles info@catcrafts.net ever sees order data — which is what the privacy page implies.

apt install msmtp

cat > /etc/msmtprc <<'CONF'
defaults
auth on
tls on
tls_starttls on
account catcrafts
host smtp.your-mail-provider.example
port 587
from info@catcrafts.net
user info@catcrafts.net
passwordeval cat /etc/catcrafts/smtp-password
account default : catcrafts
CONF
chmod 0644 /etc/msmtprc

# msmtp runs as the service user, so the password file must be readable by
# it — unlike payments.env, which only root (systemd) reads.
install -o catcrafts -g catcrafts -m 0600 /dev/null /etc/catcrafts/smtp-password
# ...then put the SMTP password in that file.

Then in /etc/catcrafts/payments.env:

MAIL_COMMAND=msmtp -t
MAIL_FROM=Catcrafts <info@catcrafts.net>

and systemctl restart catcrafts-server — the journal should say mail: order confirmations via 'msmtp -t'. Unset, no email is sent and the order page plus the invoice download remain the buyer's receipt: degraded, not down, like every optional integration here.

Worth knowing:

  • A failed handoff retries with exponential backoff (1 min doubling to a cap of ~an hour), forever — a broken relay delays the email, it never eats it. Watch journalctl -u catcrafts-server | grep 'mail:' after changing config.
  • With a signing key configured, a gpg failure means the email WAITS — an unsigned invoice never leaves by accident, same rule as the download.
  • Send a real test: --rail=fake locally with MAIL_COMMAND pointing at msmtp and your own address in the order form, or just run tools/e2e.sh, which captures the messages with a fake sendmail and verifies the attached signature.

Reading the orders ledger

catcrafts-server --orders /var/lib/catcrafts/orders.jsonl
orders: 2

reference  status             total  cc  created               token
CC-3F9A2C  paid              595.00  NL  2026-08-04T14:02:11Z  3f9a2c…
CC-91B04D  awaiting_payment  534.34  CA  2026-08-04T15:40:03Z  91b04d…

Manual transitions exist for the cases automation cannot see — a payment confirmed out-of-band, the parcel handed to the carrier, a refund:

catcrafts-server --orders /var/lib/catcrafts/orders.jsonl --mark-paid    <token>
catcrafts-server --orders /var/lib/catcrafts/orders.jsonl --mark-shipped <token>
catcrafts-server --orders /var/lib/catcrafts/orders.jsonl --cancel       <token>

Each appends a status event to the log — nothing is ever rewritten, so the file remains its own audit trail. Deleting personal data on request is an edit of the fields the seven-year fiscal retention does not cover.

The open financials page and the bunq mutation callback

/financials publishes running totals only: sales, donations, and expenses as one flat list of categories. Sales fold out of orders.jsonl on every request and need no setup at all — that half works the moment the page ships. This section is about the other half.

No bunq API key belongs on this box. A bunq key can initiate payments and there is no read-only scope, so a compromised server would be a compromised bank account. Instead the key stays on your own machine, is used there once to register a notification filter, and from then on bunq PUSHES mutations here. The server can learn that money moved without being able to move any.

What reaches the page, and what never does

A callback arrives carrying a counterparty name, an IBAN and a description. None of it is written down. The mutation is classified, its amount is added to a category total, its opaque id goes in a dedup ledger, and everything else is dropped before anything touches the disk. There is no file here that could leak a donor's identity, because no such file is ever written. tools/e2e.sh asserts exactly that, by grepping the whole state directory for a test IBAN afterwards.

Classification is default-deny: money no rule claims is withheld from the page and logged for you to write a rule for. It is never published as "other".

Setup

  1. A dedicated bunq account. Point donations at a monetary account used for nothing else. That account id is what classifies a donation, because donors are strangers and no IBAN list can know them in advance.

  2. The secret. It is the last segment of the callback URL, and setting it is what brings the endpoint into existence — unset, /api/bunq/* is an ordinary 404.

    openssl rand -hex 32     # into BUNQ_CALLBACK_SECRET in payments.env
    systemctl restart catcrafts-server
    

    The URL is https://catcrafts.net/api/bunq/<secret>. Caddy proxies /api/* straight through, so no Caddyfile change is needed, and the analytics ingest censors /api out of the public report. It is NOT censored from the private tier or from Caddy's own access log, so treat the secret the way you treat the analytics password: rotate it if logs are ever shared.

  3. The rules, at /var/lib/catcrafts/orders.jsonl.financial-rules.json. Re-read on every callback, so a new rule takes effect without a restart:

    {"donation_accounts": [9911],
     "rules": [
       {"description_contains": "hetzner", "group": "expense", "label": "Hosting"},
       {"iban": "NL00INSURER0000000", "group": "expense", "label": "Insurance"},
       {"iban": "DE00SUPPLIER000000", "group": "expense", "label": "Inventory"},
       {"iban": "NL00MOLLIE00000000", "group": "ignore"},
       {"iban": "NL00OWNSELF0000000", "group": "ignore"}]}
    

    group is donations, expense or ignore; first match wins, and explicit rules beat the donation-account default (which is how your own transfer between accounts stays out of the donation total). Ignore your Mollie and CoinGate payouts — those are sales, already counted from the ledger, and letting them through would publish that money twice. A rule with no criterion, an unknown group, or an expense with no label is dropped at load rather than allowed to claim everything.

  4. Register the filter from your own machine, with the key that lives there — tools/bunq-callback.sh does the whole handshake (installation, device-server, session) and installs a MUTATION NotificationFilterUrl:

    tools/bunq-callback.sh list                      # accounts + current filters
    tools/bunq-callback.sh set <account-id> https://catcrafts.net/api/bunq/<secret>
    

    It reads BUNQ_KEY from the repo-root .env, binds the key to this machine's address only (never the server's — permitted_ips governs who may CALL the bunq API, not where callbacks are delivered), and refuses to register a URL that is not already answering 200. Run list first and put the donations account id in donation_accounts in the rules file above.

    The device registration is permanent for that key, and your home address is probably dynamic: when it rotates, this script stops working from here (add the new address to the device in the bunq app). The callback keeps working regardless — bunq delivers outbound, so nothing about permitted_ips affects it.

  5. Optional but recommended: signature checking. Set BUNQ_CALLBACK_PUBKEY to a PEM file holding bunq's server public key and every callback must then carry a valid RSA-SHA256 signature over its body. It is off by default deliberately: the header bunq signs with has changed across API generations, and a verifier wrong about the header name rejects every real callback while looking like it works. Turn it on after you have seen a real callback arrive carrying X-Bunq-Server-Signature, and confirm afterwards that donations still land.

Operating it

  • Watch it work: journalctl -u catcrafts-server -f. A mutation no rule claimed logs its id, the running count of withheld mutations and their net total — that log line is your to-do list.
  • Every unauthorised request answers 404, never 401: the endpoint does not confirm its own existence to a prober.
  • A duplicate, a withheld and an ignored mutation all answer 200. A non-2xx makes bunq redeliver, so only a failed write earns a 500 — the one case where a retry could actually help.
  • State files, all under /var/lib/catcrafts and none worth backing up: orders.jsonl.financials.json (the published totals), orders.jsonl.financials-seen.json (ingested ids + withheld counters), and the rules file above.

The weekly reconciliation is the authority

Callbacks can be missed, replayed or arrive before a rule exists for them, so this path is allowed to be lossy but never wrong: it may withhold, it may not invent. Your home tooling recomputes every total from the full bunq mutation history and overwrites orders.jsonl.financials.json wholesale — same format, same file. That is the correction mechanism, and it is what makes it safe for the live path to publish provisionally.

Verifying a deploy

systemctl status catcrafts-server
curl -s localhost:8081/api/healthz            # ok + content counts

# real status codes, which a client-side router cannot produce
curl -o /dev/null -w '%{http_code}\n' https://catcrafts.net/nope        # 404
curl -o /dev/null -w '%{http_code}\n' https://catcrafts.net/blog        # 301

# the SEO check: content present with no JavaScript involved
curl -s https://catcrafts.net/projects | grep -c '<script'              # 0
curl -s https://catcrafts.net/projects | grep -o '<title>[^<]*'

Analytics

Server-side only — the privacy policy promises request logging and nothing else, so there is no client-side analytics anywhere on the site. GoAccess (Debian package) turns Caddy's JSON access logs into two static HTML reports, each with its own persistent DB and ingest ledger:

  • https://catcrafts.net/analytics/public, censored. Visitor IPs are anonymized at ingest (last octet zeroed before anything reaches its DB), no HOSTS or full-URL REFERRERS panels, and log lines matching CENSOR_RE in the script never enter its DB at all — the public tier cannot leak what it never ingested. CENSOR_RE covers /api and /order: an order token is the entire capability to read that buyer's status page and their invoice (name, street, postal code, city), so publishing the path publishes the buyer. Keep secrets out of URL paths regardless — query strings are already stripped, paths are not.
  • https://catcrafts.net/analytics/private/uncensored (basic auth, hash in the Caddyfile): full IPs, all panels.

Raw logs keep full IPs either way — that is the request logging the privacy policy declares; per-IP forensics work from the logs and the private tier, never from the public page.

Three pieces, all in deploy/:

  • catcrafts-analytics/usr/local/bin/ — ingests each rotated catcrafts.net-*.log.gz exactly once into a persistent GoAccess DB (/var/lib/goaccess/db, tracked in /var/lib/goaccess/ingested), then renders the report from DB + live log. The live file is never persisted, so its lines don't double-count when Caddy rotates it. History therefore survives log deletion: the DB keeps aggregates forever.
  • catcrafts-analytics.service — oneshot, runs as caddy (owner of the 0600 logs).
  • catcrafts-analytics.timer — hourly at :07.

Bot filtering is the load-bearing part: measured on real traffic, 57% of requests were headerless vulnerability scanners and another 19% self-declared bots (mostly ClaudeBot) — only ~24% human. --ignore-crawlers --unknowns-as-crawlers drops both groups. The flags in the script apply at ingest time and the DB stores aggregated data, so changing filters later only affects new lines — re-ingesting history means deleting /var/lib/goaccess/{db,ingested} and letting the next run rebuild from whatever raw logs retention still holds (a year, per the Caddyfile).

apt install goaccess
install -m 755 deploy/catcrafts-analytics /usr/local/bin/
install -m 644 deploy/catcrafts-analytics.{service,timer} /etc/systemd/system/
mkdir -p /etc/goaccess /var/lib/goaccess /var/www/analytics /var/www/analytics-private
install -m 644 deploy/goaccess-browsers.list /etc/goaccess/browsers.list
# own IPs to keep out of the numbers - host-only file, NOT in this repo
echo "203.0.113.7" > /etc/goaccess/exclude-ips
chown -R caddy:caddy /var/lib/goaccess /var/www/analytics /var/www/analytics-private
systemctl daemon-reload && systemctl enable --now catcrafts-analytics.timer

Running it locally

tools/dev.sh              # build both products and serve on :8080
tools/dev.sh --no-build   # reuse what is already in bin/

That is the whole site: Caddy in front, the backend behind, static assets from disk, cross-origin headers scoped exactly as in production. Ctrl-C stops both. Orders from the session go to a temp file and are discarded on exit; the fake payment rail is active — touch <workdir>/orders.jsonl.fake-paid plays the part of the customer paying.

Do not run catcrafts-server --serve alone and expect a working site. It serves pages only — static assets are Caddy's job — so /styles.css 404s and every page renders unstyled. That looks broken but isn't; it is the deployment split working as designed.

Other useful commands

tools/fetch-posts.sh            # pull posts from the allowed communities
tools/fetch-media.sh [dir]      # mirror their media locally (run after the above)
tools/fetch-rates.sh            # ECB reference rates for the indicative prices
tools/e2e.sh                    # ~200 HTTP checks against a real server (135 while
                                # coming-soon; the rest re-arm at launch); the CI gate
crafter-build --local -r        # the wasm app alone, no backend, on :8080

<server>/catcrafts-server --selftest            # ~260 in-process assertions
<server>/catcrafts-server --routes              # status + title for every route
<server>/catcrafts-server --render /projects    # dump one page's HTML
<server>/catcrafts-server --orders FILE         # the orders ledger + manual transitions

If a bin/ glob matches two directories

The variant directory name embeds a config hash, and --local and non---local builds hash differently: --local resolves the Crafter libraries from sibling working trees, a plain build fetches them from Forgejo. They are genuinely different configurations and both land in bin/.

Mixing them leaves two directories, and any script globbing for one picks arbitrarily — which in practice means testing a stale binary and believing the result. Every script here refuses to guess and tells you to rm -rf bin. Pick one mode and stay in it.