This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -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
|
||||
|
|
|
|||
165
deploy/README.md
165
deploy/README.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue