coingate
All checks were successful
Deploy / build-deploy (push) Successful in 3m10s

This commit is contained in:
Jorijn van der Graaf 2026-08-13 23:34:19 +02:00
commit 70668af8f5
20 changed files with 2354 additions and 1048 deletions

View file

@ -18,6 +18,46 @@ catcrafts.net {
Referrer-Policy "strict-origin-when-cross-origin"
X-Content-Type-Options "nosniff"
-Server
# HSTS. Caddy redirects http->https but does NOT send this header on
# its own, so without it a first visit over http is still interceptable
# and every later one is only as safe as the redirect. A shop taking
# card payments should not be relying on a redirect.
#
# includeSubDomains commits EVERY catcrafts.net subdomain to HTTPS —
# www and forgejo are both on TLS here, so it holds. Drop that token if
# a subdomain ever has to serve plaintext. `preload` is deliberately
# NOT set: submission to the browser preload list is months to undo,
# and it should be a decision, not a side effect of this file.
Strict-Transport-Security "max-age=31536000; includeSubDomains"
# CSP. Defence in depth rather than the primary control — markup is
# built through Catcrafts.Shared:Html, where escaping is enforced by
# the type system and forgetting is a compile error. What this adds is
# the damage limitation that escaping cannot provide:
#
# form-action 'self' the checkout form cannot be retargeted at
# another origin — the directive that matters
# most on a page that collects an address
# frame-ancestors no clickjacking the buy button
# base-uri 'none' an injected <base> cannot re-point every
# relative script src on the page
# object-src 'none' no plugin content, ever
#
# 'unsafe-inline' in script-src is a known and bounded compromise: the
# geo price hint (kGeoPriceHintScript) must run before first paint to
# set a class on <html> without a flash, so it is inline by design.
# Removing it means hashing that constant here and re-hashing on every
# edit — silently breaking the hint when someone forgets. To tighten
# this properly, move the script to a real file and give it a nonce.
# 'wasm-unsafe-eval' is what the WASM runtime needs to compile the
# module; it does not enable eval() for JavaScript.
#
# img-src and media-src allow https: because a post whose media mirror
# failed still points at the source instance's URL (see Media::Describe)
# — locking those to 'self' would blank exactly the pictures a post is
# talking about.
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' https:; font-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
}
# ── cross-origin isolation, scoped ────────────────────────────────────
@ -110,6 +150,18 @@ catcrafts.net {
# Pages, /feed.xml, /sitemap.xml and later /api/*. The backend sets its own
# Cache-Control and returns real status codes — a 404 for an unknown path and
# a 301 for the retired /blog URLs, which a client-side router cannot do.
# Checkout rate limiting is per-peer, and this block is what makes that
# possible: reverse_proxy APPENDS the real client address to
# X-Forwarded-For, and the backend reads the rightmost entry (see
# ClientAddressFromForwarded — the leftmost is whatever the client claimed).
# That is only sound while nothing but Caddy can reach 8081, which is why
# the backend binds loopback and why the warning at the top of this file
# says not to expose the port.
#
# Caddy's own rate_limit directive is a third-party module and is NOT in a
# standard build — adding it here without rebuilding Caddy stops the server
# from starting. If volume ever justifies limiting at the edge, build Caddy
# with github.com/mholt/caddy-ratelimit first.
handle {
reverse_proxy 127.0.0.1:8081 {
health_uri /api/healthz

View file

@ -15,8 +15,9 @@ 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)
and `bunq-state.json` (the bunq session context, including the client RSA key
the server generates on first contact).
and `orders.jsonl.shipping.json` (the cached carrier rate table). 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.
@ -33,8 +34,8 @@ cp /var/lib/catcrafts/orders.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.
(`bunq-state.json` is deliberately NOT worth backing up: delete it and the
server re-onboards from the API key on the next start.)
(The cached shipping table is deliberately NOT worth backing up: delete it and
the next Sendcloud refresh rebuilds it.)
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
@ -265,57 +266,97 @@ 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 setup
## Payments: Mollie and CoinGate
The rail is Mollie (bunq.me was measured and disqualified: €500/transaction on
cards and no method at all for a non-EU buyer at phone prices — it is a P2P
tool; the bunq client remains in the tree, unused, in case an account sweep is
ever wanted). The server needs exactly one secret: the Mollie API key.
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.
```sh
# Keys live in the Mollie dashboard: Developers -> API keys. A test_… key
# works against the real API from the moment the account exists — verify the
# whole flow with it BEFORE swapping in the live_… key.
# 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 # should say "payments: mollie"
journalctl -u catcrafts-server | tail # "payments: bank=mollie crypto=coingate"
```
Without the env file the server starts with payments off: the whole site works,
the product page renders, and checkout answers 503 with an honest message —
degraded, not down.
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 (`GET /v2/payments/{id}`) every 10 s
while fresh, backing off with age. `?redirect` back from Mollie is ignored
by design — only the authenticated poll moves an order to paid, and the
paid event records the method (`ideal`, `creditcard`, …) in the ledger.
* Mollie payments EXPIRE. A payment that reaches canceled/expired/failed
lapses the order automatically — the buyer just orders again.
* 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
and bank transfers are final; `creditcard` is the one with a tail.
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.
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 (optional)
## Shipping rates: Sendcloud (REQUIRED to sell)
Without configuration, shipping is priced by the three-zone table in
the compiled-in product data (NL / EU / world, Catcrafts.Shared-Content.cppm) — honest flat rates you set. With a
Sendcloud account, the server fetches the real per-country prices of one
shipping method daily and uses those instead, falling back to the zones for
any country the method does not cover:
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
```
```sh
# credentials from Sendcloud: Settings -> Integrations -> API
cat >> /etc/catcrafts/bunq.env <<'ENV'
cat >> /etc/catcrafts/payments.env <<'ENV'
SENDCLOUD_PUBLIC_KEY=...
SENDCLOUD_SECRET_KEY=...
SENDCLOUD_METHOD='PostNL Parcels non-EU,DPD Home'
@ -325,18 +366,48 @@ journalctl -u catcrafts-server | grep shipping: # "table refreshed (N countrie
```
`SENDCLOUD_METHOD` is a comma-separated list of name substrings, merged in
order with the FIRST match per country winning — 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. The fetched table is
cached next to the orders file so a restart during a Sendcloud outage keeps
the last known prices. Like the bunq client, this integration is UNTESTED
against the live API until credentials exist — the response parser is covered
by --selftest, the fetch around it is thin.
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, and the amount is computed server-side at
order time from the same data.
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)
@ -451,7 +522,7 @@ 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 bunq
Manual transitions exist for the cases automation cannot see — a payment
confirmed out-of-band, the parcel handed to the carrier, a refund:
```sh
@ -490,9 +561,11 @@ reports, each with its own persistent DB and ingest ledger:
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. Extend `CENSOR_RE` when the shop launches so
order/payment URLs can never surface; keep secrets out of URL *paths*
regardless (query strings are already stripped).
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.

View file

@ -27,9 +27,21 @@ STATE_DIR=/var/lib/goaccess
OUT_PUBLIC=/var/www/analytics/index.html
OUT_PRIVATE=/var/www/analytics-private/index.html
# Log lines whose URI matches this never enter the public tier. Extend it
# when the shop launches so order/payment URLs can never surface publicly.
CENSOR_RE='"uri":"/api'
# Log lines whose URI matches this never enter the public tier.
#
# /order MUST be here. An order token is not an identifier, it is the whole
# capability: /order/<token> is the buyer's status page and
# /order/<token>/invoice.md is their name, street, postal code and city. The
# public report lists requested paths (only the HOSTS and REFERRERS panels are
# suppressed, and --no-query-string does nothing for a token that lives in the
# PATH), so an uncensored public tier would have published a harvestable index
# of every buyer's address within an hour of the first sale.
#
# Filters apply at INGEST, so this must be correct BEFORE the shop opens.
# Should an order URL ever reach the public DB, changing this line is not
# enough — follow the re-filter procedure at the top of this file to rebuild
# from the retained raw logs.
CENSOR_RE='"uri":"/(api|order)'
# Serialize runs: a manual run racing the hourly timer once ingested the same
# rotated log twice (both processes passed the ledger check before either

View file

@ -35,8 +35,7 @@ WorkingDirectory=/srv/catcrafts-app
ExecStart=/srv/catcrafts-app/catcrafts-server --serve 8081 \
--content=/srv/catcrafts-app/content \
--webroot=/srv/catcrafts.net \
--orders=/var/lib/catcrafts/orders.jsonl \
--bunq-state=/var/lib/catcrafts/bunq-state.json
--orders=/var/lib/catcrafts/orders.jsonl
Restart=always
RestartSec=2s
@ -76,17 +75,17 @@ ReadOnlyPaths=/srv/catcrafts-app /srv/catcrafts.net
# Secrets arrive from OUTSIDE the deployed tree — the web root is public and
# rsync-wiped, and /srv/catcrafts-app is CI-writable; neither may ever hold a
# credential. /etc/catcrafts/payments.env (root:root 0600) carries:
# MOLLIE_API_KEY=live_... (or test_... while verifying) — the rail
# SENDCLOUD_PUBLIC_KEY / SENDCLOUD_SECRET_KEY / SENDCLOUD_METHOD — optional,
# live shipping rates; zone table without them
# MOLLIE_API_KEY=live_... (or test_... while verifying) — the BANK rail
# COINGATE_API_KEY=... the CRYPTO rail; omit and checkout offers only
# COINGATE_SANDBOX=1 bank. Sandbox tokens are not live tokens.
# SENDCLOUD_PUBLIC_KEY / SENDCLOUD_SECRET_KEY / SENDCLOUD_METHOD — REQUIRED
# to sell: no rate table means checkout refuses
# INVOICE_GPG_KEY=... invoice signing (see deploy/README.md)
# MAIL_COMMAND=msmtp -t order confirmation email (see deploy/README.md,
# MAIL_FROM=... "Order email"); unset = no email is sent
# BUNQ_API_KEY=... legacy: only used when no Mollie key is set
# The '-' prefix makes the file optional: without it the server starts with
# payments off and the shop renders but refuses checkout — degraded, not down.
EnvironmentFile=-/etc/catcrafts/payments.env
EnvironmentFile=-/etc/catcrafts/bunq.env
# Invoice signing keyring (see deploy/README.md, "Invoice signing").
Environment=GNUPGHOME=/var/lib/catcrafts/gnupg