This commit is contained in:
parent
33c68c2f44
commit
749f525f83
44 changed files with 5380 additions and 3532 deletions
172
deploy/README.md
172
deploy/README.md
|
|
@ -103,10 +103,12 @@ 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.
|
||||
`catcrafts-deploy.service` runs `catcrafts-server --render /` as `ExecStartPre`
|
||||
before restarting. The unit tests already gated the deploy in CI
|
||||
(`crafter-build test --product=server`); this on-host check covers what CI
|
||||
cannot — the dynamic loader finding libmsquic on this machine and a page
|
||||
rendering from the deployed content — so **a broken binary leaves the working
|
||||
server running** instead of replacing it.
|
||||
|
||||
The binary is installed as `catcrafts-server.new` and `mv`d into place, so a
|
||||
request arriving mid-copy never hits a truncated executable.
|
||||
|
|
@ -208,7 +210,7 @@ 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
|
||||
the deploy then failed the "media origin" check (ShouldServePostPages) 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
|
||||
|
|
@ -243,14 +245,15 @@ Two things to know about publishing AV1:
|
|||
|
||||
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
|
||||
without its media, and failing the origin check (ShouldServePostPages) 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.
|
||||
so the ShouldServePostPages suite asserts they are present, and a build host
|
||||
missing the package fails at the test 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
|
||||
|
|
@ -272,78 +275,118 @@ 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
|
||||
## Payments: Mollie and EURC
|
||||
|
||||
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 |
|
||||
| `crypto` | EURC | `EURC_CHAINS` | self-hosted EURC on the configured EVM chains |
|
||||
|
||||
The slots are independent. Set one key and the form offers only that method;
|
||||
The slots are independent. Set one 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.
|
||||
mode whose credential 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.
|
||||
|
||||
### The EURC rail
|
||||
|
||||
No processor, no API key, no account anywhere: the buyer sends EURC (Circle's
|
||||
euro stablecoin, pegged 1:1 — the token amount IS the euro total) to an
|
||||
address from a pool this shop generated offline, and the server notices by
|
||||
polling `balanceOf` over JSON-RPC at a finalized block. The order page is the
|
||||
payment page. Three pieces of configuration:
|
||||
|
||||
- `EURC_CHAINS=/etc/catcrafts/eurc-chains.json` — which chains to watch.
|
||||
Selecting this rail IS setting this variable. One EVM address is valid on
|
||||
every chain at once, so the buyer pays on whichever is cheapest; **file
|
||||
order is display order** — put the cheap chain (Base) first, and its
|
||||
`note` ("lowest network fees") is the nudge the buyer sees.
|
||||
|
||||
```json
|
||||
{"chains": [
|
||||
{"name": "base", "rpc": "https://mainnet.base.org",
|
||||
"contract": "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
|
||||
"chain_id": 8453, "note": "lowest network fees"},
|
||||
{"name": "ethereum", "rpc": "https://ethereum-rpc.publicnode.com",
|
||||
"contract": "0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c",
|
||||
"chain_id": 1}
|
||||
]}
|
||||
```
|
||||
|
||||
Contract addresses come from Circle's own list
|
||||
(developers.circle.com/stablecoins/eurc-contract-addresses) and nowhere
|
||||
else — matching the CONTRACT, not the ticker, is what makes a fake "EURC"
|
||||
worthless here. EURC exists on Ethereum, Base, Avalanche, Cronos and World
|
||||
Chain (not Arbitrum/Optimism/Polygon/BNB).
|
||||
|
||||
- `EURC_POOL` (default `<orders>.eurc-addresses`) — one receiving address
|
||||
per line, `#` comments allowed. Generated by the wallet at home; the box
|
||||
holds NO key, NO xpub, and can only hand out addresses it was given.
|
||||
**Copy addresses, never retype**: the server cannot verify the EIP-55
|
||||
checksum, so a retyped-but-still-hex address is accepted and published.
|
||||
**Append only**: `<pool>.cursor` is an index into this file, so reordering
|
||||
re-issues addresses already bound to old orders. Duplicates and malformed
|
||||
lines are startup refusals; exhaustion too — the low-water warning fires
|
||||
at 25 left.
|
||||
|
||||
- `EURC_WINDOW_HOURS` (default 24) — how long an order may sit unpaid. A
|
||||
lapsed window is NOT bounced money: the address stays ours, a late payment
|
||||
still lands there (the server logs exactly this), and it is settled by
|
||||
hand with `--mark-paid`.
|
||||
|
||||
```sh
|
||||
# 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.
|
||||
# Mollie keys: dashboard -> Developers -> API keys. Mollie's test mode works
|
||||
# against the real endpoints — verify the whole flow before swapping in the
|
||||
# live key. The EURC rail has no key and no test mode: point EURC_CHAINS at a
|
||||
# testnet chains file (Base Sepolia / Ethereum Sepolia contracts are in
|
||||
# Circle's list) to rehearse against worthless tokens instead.
|
||||
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
|
||||
EURC_CHAINS=/etc/catcrafts/eurc-chains.json
|
||||
ENV
|
||||
chmod 0600 /etc/catcrafts/payments.env
|
||||
systemctl restart catcrafts-server
|
||||
journalctl -u catcrafts-server | tail # "payments: bank=mollie crypto=coingate"
|
||||
journalctl -u catcrafts-server | tail # "payments: bank=mollie crypto=eurc"
|
||||
```
|
||||
|
||||
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.
|
||||
Mollie every 10 s, the EURC rail every 30 s (a finalized block will not
|
||||
arrive faster), both backing off with age. Arriving back on the order page
|
||||
is ignored as evidence by design: only the authenticated poll (Mollie) or
|
||||
the rail's own RPC check (EURC) moves an order to paid, and the paid event
|
||||
records the method (`ideal`, `creditcard`, `eurc-base`, …) in the ledger.
|
||||
* **Unpaid orders lapse automatically** and the buyer just orders again.
|
||||
Mollie expires its own payments; the EURC rail closes its window after
|
||||
`EURC_WINDOW_HOURS` (24 by default). A lapsed EURC order is NOT bounced
|
||||
money — see above.
|
||||
* The shop **holds EURC**: crypto revenue sits at the pool addresses until
|
||||
swept, and nothing lands on the bank account until it is sold for euros at
|
||||
an exchange. Bookkeeping note: a EURC sale is still a euro sale on the
|
||||
invoice — EURC is euro-denominated, so there is no rate and no revaluation —
|
||||
but the financials bank-callback never sees it, so crypto sales exist in
|
||||
the ledger only until the sweep's SEPA leg arrives.
|
||||
* 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.
|
||||
bank transfers and EURC 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.
|
||||
They are — and ShouldStayScriptFree fails the build if a PLACEHOLDER marker ever
|
||||
reaches a rendered page again. The EURC rail has no onboarding, no KYB and
|
||||
no account anywhere; that is the reason it exists.
|
||||
* 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.
|
||||
no custody for others, no transfer for third parties, so no licence is
|
||||
required, with or without a processor. Cashing EURC out to the bank goes
|
||||
through an exchange, and THAT party must be a MiCA-authorised CASP (check
|
||||
the ESMA register) — but it stands after the checkout, not in it.
|
||||
* VAT is unchanged by payment method: the sale is priced and invoiced in euro
|
||||
and taxed on the euro value, whichever rail settled it.
|
||||
|
||||
|
|
@ -398,14 +441,13 @@ Sendcloud outage keeps selling at the last known prices. Format is
|
|||
(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
|
||||
dev and the black-box suites 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
|
||||
This integration is UNTESTED against the live API until credentials exist —
|
||||
the response parser is covered by the ShouldParseSendcloudRates test, 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.
|
||||
|
|
@ -510,9 +552,10 @@ Worth knowing:
|
|||
* 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.
|
||||
msmtp and your own address in the order form, or just run the
|
||||
ShouldProcessCheckout suite (`crafter-build test ShouldProcessCheckout
|
||||
--product=server`), which captures the messages with a fake sendmail and
|
||||
verifies the attached signature.
|
||||
|
||||
## Reading the orders ledger
|
||||
|
||||
|
|
@ -560,8 +603,9 @@ 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.
|
||||
a donor's identity, because no such file is ever written. ShouldServeFinancialsLive
|
||||
asserts exactly that, by scanning 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".
|
||||
|
|
@ -603,7 +647,8 @@ page and logged for you to write a rule for. It is never published as "other".
|
|||
`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
|
||||
Mollie payouts, and one day the exchange's SEPA leg from selling EURC** —
|
||||
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.
|
||||
|
|
@ -757,11 +802,12 @@ split working as designed.
|
|||
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
|
||||
crafter-build test --product=server --local # every suite: the unit assertions
|
||||
# AND the black-box HTTP suites, which spawn the
|
||||
# real server on scratch ports (the CI gate; the
|
||||
# checkout lifecycle re-arms at launch)
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -11,10 +11,20 @@ Documentation=https://forgejo.catcrafts.net/Catcrafts/catcrafts.net
|
|||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
# Verify the freshly deployed binary before cutting over. --selftest exercises
|
||||
# the escaping and JSON layers that produce every byte of markup the site
|
||||
# emits; if it fails, the currently-running (working) server is left alone
|
||||
# rather than replaced with a broken one.
|
||||
ExecStartPre=/srv/catcrafts-app/catcrafts-server --selftest
|
||||
# Verify the freshly deployed binary before cutting over. The unit tests run
|
||||
# in CI (crafter-build test) before anything is shipped, so what this gate
|
||||
# checks is the part CI cannot: that THIS binary executes on THIS host — the
|
||||
# dynamic loader finds libmsquic (the first real deploy died exactly there,
|
||||
# exit 127) — and renders a page from the deployed content. If it fails, the
|
||||
# currently-running (working) server is left alone rather than replaced with
|
||||
# a broken one.
|
||||
#
|
||||
# WorkingDirectory because --render loads ./content; StandardOutput=null so
|
||||
# the rendered HTML does not land in the journal on every deploy (stderr,
|
||||
# where failures speak, still does).
|
||||
WorkingDirectory=/srv/catcrafts-app
|
||||
StandardOutput=null
|
||||
StandardError=journal
|
||||
ExecStartPre=/srv/catcrafts-app/catcrafts-server --render /
|
||||
|
||||
ExecStart=/usr/bin/systemctl restart catcrafts-server.service
|
||||
|
|
|
|||
|
|
@ -76,8 +76,10 @@ ReadOnlyPaths=/srv/catcrafts-app /srv/catcrafts.net
|
|||
# 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 BANK rail
|
||||
# COINGATE_API_KEY=... the CRYPTO rail; omit and checkout offers only
|
||||
# COINGATE_SANDBOX=1 bank. Sandbox tokens are not live tokens.
|
||||
# EURC_CHAINS=/etc/catcrafts/eurc-chains.json — the CRYPTO rail (self-hosted
|
||||
# EURC; no key). Omit and checkout offers only bank.
|
||||
# The address pool defaults to
|
||||
# /var/lib/catcrafts/orders.jsonl.eurc-addresses.
|
||||
# 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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue