diff --git a/.claude/settings.json b/.claude/settings.json
deleted file mode 100644
index 1b0a1c0..0000000
--- a/.claude/settings.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "permissions": {
- "allow": [
- "Bash(cd ../Crafter/Crafter.Graphics && echo \"=== C++ files declaring wgpu* imports ===\"; grep -rln 'import_name\\(\"wgpu' implementations/ interfaces/; echo; echo \"=== sample: how a wgpu import + public wrapper is declared \\(canvas size + a string-taking one\\) ===\"; grep -rn -B2 -A2 -E 'import_name\\\\\\(\"wgpu\\(GetCanvasWidth|SurfaceWidth|LoadCustomShader|Init\\)\"' implementations/ interfaces/ | head -50)",
- "Read(//home/jorijn/repos/Crafter/Crafter.Graphics/**)",
- "Read(//home/jorijn/repos/Crafter/Crafter.Graphics/interfaces/**)",
- "Read(//home/jorijn/repos/Crafter/Crafter.Graphics/additional/**)",
- "Bash(crafter-build --local)"
- ],
- "additionalDirectories": [
- "/home/jorijn/repos/Crafter/Crafter.Graphics/additional",
- "/home/jorijn/repos/Crafter/Crafter.Graphics/interfaces"
- ]
- }
-}
diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml
index 2f5c799..1e31b0f 100644
--- a/.forgejo/workflows/deploy.yaml
+++ b/.forgejo/workflows/deploy.yaml
@@ -27,11 +27,17 @@ jobs:
# run inside this archlinux container — the runner execs them with
# node. This shell step needs no node, so installing it here (before
# Checkout) is enough.
+ # ffmpeg is for ffprobe, which tools/fetch-media.sh uses to read the
+ # pixel dimensions of each mirrored file. Those become the width/height
+ # attributes that stop the posts page reflowing as 5 MB recordings
+ # arrive, and tools/e2e.sh asserts they are present — so without this
+ # package the deploy fails at the e2e gate rather than shipping a
+ # janky page.
pacman -Syu --noconfirm --needed \
nodejs \
clang lld libc++ \
wasi-libc wasi-libc++ wasi-libc++abi wasi-compiler-rt \
- git curl tar rsync
+ git curl tar rsync zstd gzip jq openssl ffmpeg gnupg
# Container runs as root; workspace may be owned by another uid.
git config --global --add safe.directory '*'
@@ -63,6 +69,79 @@ jobs:
restore-keys: |
crafter-cache-${{ runner.os }}-
+ - name: Fetch ECB reference rates
+ # Feeds the indicative national-currency line on order pages. Every
+ # charge is in euros; this is display only, labelled with its date —
+ # which is why build-time freshness is enough and no rate service is
+ # ever called at page-view time. Exits 0 on failure: a stale rate
+ # (or none — the page then shows only euros) must not fail a deploy.
+ run: tools/fetch-rates.sh
+
+ - name: Fetch fediverse posts
+ # Build-time, not run-time: the site embeds the owner's own posts and
+ # links out for discussion, so there is no sync service and no runtime
+ # dependency on the instance being up. The script leaves the committed
+ # content/posts.json untouched and exits 0 on any failure, so a
+ # fediverse outage cannot fail a deploy.
+ run: tools/fetch-posts.sh
+
+ - name: Mirror post media
+ # Downloads the images and screen recordings the posts carry and rewrites
+ # content/posts.json to point at our own copies, so nothing the browser
+ # loads is third-party — which is what keeps the privacy notice's
+ # "everything comes from catcrafts.net" true.
+ #
+ # Content-addressed and incremental: a file already on the media mount is
+ # never downloaded again. Writes straight into the mount so the copies
+ # persist across deploys — they are NOT always reproducible, because a
+ # source instance deleting a file leaves ours as the only one.
+ run: |
+ set -eu
+ if [ -d /deploy-app ]; then
+ mkdir -p /deploy-app/media
+ tools/fetch-media.sh /deploy-app/media
+ else
+ echo "WARNING: /deploy-app not mounted; mirroring to a throwaway dir." >&2
+ echo "Media will be re-downloaded on every build until the mount exists." >&2
+ tools/fetch-media.sh media
+ fi
+
+ - name: Build and test the backend
+ id: srv
+ # The server product builds Catcrafts.Shared for the host, which is the
+ # only way to actually RUN the code that generates every byte of markup
+ # the site emits. --selftest is a gate: if escaping or the JSON reader
+ # regress, the deploy stops here rather than shipping broken pages.
+ #
+ # Same refuse-to-guess rule as the wasm bundle below: a variant
+ # directory embeds a config hash, so more than one match means the tree
+ # is ambiguous and picking the first would deploy an arbitrary build.
+ run: |
+ set -eux
+ crafter-build -- --product=server
+ matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Server-*' | sort)
+ count=$(printf '%s\n' "$matches" | grep -c . || true)
+ if [ "$count" -ne 1 ]; then
+ echo "Expected exactly one Catcrafts.Server-* directory, found $count:" >&2
+ printf '%s\n' "$matches" >&2
+ exit 1
+ fi
+ echo "srv=$matches" >> "$GITHUB_OUTPUT"
+ "$matches/catcrafts-server" --selftest
+ "$matches/catcrafts-server" --routes
+
+ - name: Generate sitemap and Atom feed
+ # Both come from the same route table and Post model the pages use, so
+ # they cannot drift from what the site serves. Generated BEFORE the wasm
+ # build so cfg.files picks them up into the bundle.
+ env:
+ SRV: ${{ steps.srv.outputs.srv }}
+ run: |
+ set -eux
+ "$SRV/catcrafts-server" --sitemap > sitemap.xml
+ "$SRV/catcrafts-server" --feed > feed.xml
+ head -n 4 sitemap.xml
+
- name: Build (wasm bundle)
run: crafter-build
@@ -70,16 +149,78 @@ jobs:
id: out
run: |
set -eu
- dist=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Net-wasm32-wasip1-*' | head -n1)
- if [ -z "$dist" ]; then
+ # The directory name embeds a config hash, so glob for it. Any change
+ # to compile/link flags produces a NEW hash, which is why we refuse to
+ # guess when more than one variant is present rather than taking
+ # whichever the filesystem happened to list first.
+ matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Net-wasm32-wasip1-*' | sort)
+ count=$(printf '%s\n' "$matches" | grep -c . || true)
+ if [ "$count" -eq 0 ]; then
echo "No build output directory found under bin/" >&2
ls -la bin || true
exit 1
fi
+ if [ "$count" -gt 1 ]; then
+ echo "Ambiguous build output — $count variant directories under bin/:" >&2
+ printf '%s\n' "$matches" >&2
+ echo "Refusing to guess which one to deploy. Clean bin/ and rebuild." >&2
+ exit 1
+ fi
+ dist=$matches
echo "dist=$dist" >> "$GITHUB_OUTPUT"
echo "Built bundle: $dist"
ls -la "$dist"
+ - name: Make the static shell depth-safe
+ # Caddy serves this index.html directly when the backend is down, at
+ # whatever URL was requested — including two-segment ones like
+ # /demos/raytracer. Crafter.Build emits relative boot scripts and its
+ # runtime.js fetches variants.json/files.json/the wasm relative to the
+ # DOCUMENT, so at any depth the fallback loads nothing at all. The script
+ # roots the tags and adds , and fails loudly rather than
+ # silently no-opping. The SSR path handles itself; this is only the
+ # backend-down fallback.
+ env:
+ DIST: ${{ steps.out.outputs.dist }}
+ run: tools/fix-bundle-depth.sh "$DIST"
+
+ - name: End-to-end HTTP tests
+ # Starts the freshly built server on a scratch port and exercises it over
+ # real HTTP: status codes, redirects, headers, form submission, and the
+ # no-JavaScript guarantee. --selftest covers the pure functions; only a
+ # real request can show that /nope is a 404 rather than a soft 404, that
+ # /projects contains its content with no ", pos);
+ if (end == std::string::npos) break;
+ gBootScripts += RootRelativeSrc(index.substr(pos, end + 9 - pos));
+ gBootScripts += '\n';
+ pos = end + 9;
+ }
+ }
+}
+
+std::size_t ContentPostCount() { return gContent.posts.size(); }
+std::size_t ContentProjectCount() { return gContent.projects.size(); }
+std::size_t ContentProductCount() { return gContent.products.size(); }
+
+void ConfigurePayments(std::unique_ptr rail, std::string redirectBase) {
+ gRail = std::move(rail);
+ if (!redirectBase.empty()) gRedirectBase = std::move(redirectBase);
+}
+
+namespace {
+
+// The reconciler: the ONLY thing that moves an order to paid.
+//
+// The design rule from the plan holds even without webhooks: payment state
+// comes from an authenticated poll against the provider, never from anything
+// the client (or a redirect parameter) says. This thread sweeps awaiting
+// orders and asks the rail; a positive answer appends a status event.
+//
+// Poll pacing backs off with order age — a buyer mid-flow gets answers in
+// seconds, a day-old order gets checked hourly, and after seven days the
+// order stops being polled (a very late payment is then found by the manual
+// CLI path, which exists for exactly that).
+void ReconcilerLoop(const std::stop_token& stop) {
+ std::unordered_map lastPoll;
+
+ while (!stop.stop_requested()) {
+ std::this_thread::sleep_for(gRail->PollInterval());
+ if (stop.stop_requested()) break;
+
+ const auto now = std::chrono::steady_clock::now();
+ for (const OrderRecord& order : ListOrders()) {
+ if (order.status != "awaiting_payment") {
+ lastPoll.erase(order.token);
+ continue;
+ }
+ // Age from the record's own timestamp is string math we don't
+ // need: steady-clock first-seen is good enough for backoff.
+ auto [it, inserted] = lastPoll.try_emplace(order.token, now);
+ if (!inserted) {
+ const auto sinceFirst = now - it->second;
+ // it->second tracks FIRST time seen; store poll pacing in a
+ // parallel structure? One map is enough: after the first
+ // pass, re-poll every interval for 2 h, then only every
+ // 10 min, dropping to nothing after 7 days.
+ using namespace std::chrono;
+ if (sinceFirst > hours(24 * 7)) continue;
+ if (sinceFirst > hours(2)) {
+ // Coarse modulo pacing: only act on passes that land in
+ // the first interval of every 10-minute window.
+ const auto inWindow = duration_cast(sinceFirst) % minutes(10);
+ if (inWindow > gRail->PollInterval() * 2) continue;
+ }
+ }
+
+ // Paid, lapsed (the provider says the payment can never arrive),
+ // or nothing to report — the shared step handles the transition.
+ if (PollAndAdvance(order)) lastPoll.erase(order.token);
+ }
+ }
+}
+
+} // namespace
+
+int Serve(std::uint16_t port) {
+ // Exact-match routes for the fixed set, and a fallback for everything else.
+ //
+ // The fallback is what makes this work at all: the app's own ParseRoute is
+ // the single route table shared with the wasm frontend, so rather than
+ // enumerate paths here (and risk the two disagreeing), unmatched requests
+ // are handed straight to it. It also means /shop/ and /order/
+ // need no listener change when they arrive — they are just more paths
+ // ParseRoute already knows about.
+ std::unordered_map> routes{
+ { "/sitemap.xml", [](const HTTPRequest&) { return ServeSitemap(); } },
+ { "/feed.xml", [](const HTTPRequest&) { return ServeFeed(); } },
+ { "/api/healthz", [](const HTTPRequest&) { return ServeHealth(); } },
+ };
+
+ auto fallback = [](const HTTPRequest& req) -> HTTPResponse {
+ // A POST to a product page is a checkout submission.
+ if (req.method == "POST") {
+ const Route route = ParseRoute(PathWithoutQueryHTTP(req.path));
+ if (route.kind == RouteKind::Product) return HandleCheckout(req, route);
+ HTTPResponse res;
+ res.status = "405";
+ res.headers["allow"] = "GET, HEAD";
+ res.headers["content-type"] = "text/plain; charset=utf-8";
+ res.body = "Method not allowed\n";
+ return res;
+ }
+ // Only GET and HEAD reach a page. Anything else against a page URL is a
+ // client error, and answering 405 with Allow is more useful than
+ // rendering a page for a request that will be silently ignored.
+ if (req.method != "GET" && req.method != "HEAD") {
+ HTTPResponse res;
+ res.status = "405";
+ res.headers["allow"] = "GET, HEAD, POST";
+ res.headers["content-type"] = "text/plain; charset=utf-8";
+ res.body = "Method not allowed\n";
+ return res;
+ }
+ return RenderPage(req.path);
+ };
+
+ // The reconciler only exists when there is a rail to ask. jthread: the
+ // stop token fires on destruction, so shutdown does not hang on a sleep.
+ std::optional reconciler;
+ if (gRail) {
+ reconciler.emplace([](std::stop_token st) { ReconcilerLoop(st); });
+ }
+
+ // Shipping rates: one fetch at startup, then daily. RefreshShippingTable
+ // is a no-op without Sendcloud credentials, and every failure mode leaves
+ // the previous table (cached or zone fallback) in charge.
+ std::jthread shippingRefresher([](std::stop_token st) {
+ RefreshShippingTable();
+ while (!st.stop_requested()) {
+ for (int i = 0; i < 24 * 60 && !st.stop_requested(); ++i) {
+ std::this_thread::sleep_for(std::chrono::minutes(1));
+ }
+ if (!st.stop_requested()) RefreshShippingTable();
+ }
+ });
+
+ ListenerHTTP1 listener(port, std::move(routes), std::move(fallback));
+ std::println("catcrafts-server: listening on 127.0.0.1:{} "
+ "({} projects, {} posts, payments: {})",
+ port, gContent.projects.size(), gContent.posts.size(),
+ gRail ? gRail->Name() : "off");
+ listener.Listen();
+ return 0;
+}
+
+} // namespace Catcrafts::Server
diff --git a/server/implementations/Catcrafts.Server-Invoice.cpp b/server/implementations/Catcrafts.Server-Invoice.cpp
new file mode 100644
index 0000000..8413f49
--- /dev/null
+++ b/server/implementations/Catcrafts.Server-Invoice.cpp
@@ -0,0 +1,186 @@
+/*
+catcrafts.net
+Copyright (C) 2026 Catcrafts
+
+The source code of this website is made available for viewing purposes only.
+No permission is granted to copy, modify, distribute, or create derivative works.
+*/
+
+// Invoices: markdown, clearsigned with GPG.
+//
+// Markdown because an invoice's job is to be READ — by the buyer, by an
+// accountant, by a tax office, in thirty years, with any text editor. A
+// clearsigned document keeps the text human-readable with the signature
+// inline (gpg --verify checks it), so authenticity does not depend on this
+// server still existing — which is the point: the buyer downloads the file
+// once and the shop makes no promise to host receipt pages forever.
+//
+// Signing shells out to the gpg binary rather than linking a PGP library:
+// the key management story (GNUPGHOME, agent, key generation) is exactly the
+// part a library reimplements badly, and the server signs a handful of
+// documents per week. The subprocess writes to files under a private
+// directory, never a shell-interpolated user string — the only variable in
+// the command line is the key id, validated to a safe alphabet.
+
+module;
+#include
+#include
+module Catcrafts.Server;
+
+import std;
+import Catcrafts.Shared;
+
+namespace Catcrafts::Server {
+
+namespace {
+
+std::string gGpgKeyId;
+
+// The registered business identity. On every invoice — these are the fields
+// a Dutch invoice must carry along with the sequential number and amounts.
+constexpr std::string_view kSellerName = "Catcrafts";
+constexpr std::string_view kSellerStreet = "Chico Mendesring 256";
+constexpr std::string_view kSellerCity = "3315NN Dordrecht";
+constexpr std::string_view kSellerKvk = "78437059";
+constexpr std::string_view kSellerVat = "NL003329281B38";
+constexpr std::string_view kSellerSite = "catcrafts.net";
+
+} // namespace
+
+std::string BuildInvoiceMarkdown(const OrderRecord& o,
+ std::string_view productName,
+ std::string_view colorLabel) {
+ std::string md;
+ md.reserve(2048);
+
+ const std::string item = colorLabel.empty()
+ ? std::string(productName)
+ : std::format("{} — {}", productName, colorLabel);
+
+ // The number scheme continues the pre-shop administration: the customer
+ // number is a UUID series, the invoice number counts within it.
+ const std::size_t dash = o.invoiceNumber.size() > 37 ? 36 : std::string::npos;
+ const std::string customer = dash != std::string::npos
+ ? o.invoiceNumber.substr(0, 36) : o.invoiceNumber;
+ const std::string seq = dash != std::string::npos
+ ? o.invoiceNumber.substr(37) : o.invoiceNumber;
+
+ md += std::format("# Invoice {}\n\n", o.invoiceNumber);
+ md += std::format("**{}** \n{} \n{} \nKVK {} · VAT {} · {}\n\n",
+ kSellerName, kSellerStreet, kSellerCity,
+ kSellerKvk, kSellerVat, kSellerSite);
+ // "*" bullets, never "-": clearsigning dash-escapes lines that start
+ // with a dash ("- - Invoice date"), and the raw file is meant to be read.
+ md += std::format("* Customer number: {}\n", customer);
+ md += std::format("* Invoice number: {}\n", seq);
+ md += std::format("* Invoice date: {}\n", o.invoicedAt);
+ md += std::format("* Order reference: {}\n", o.reference);
+ md += std::format("* Order placed: {}\n", o.createdAt);
+ if (!o.paidVia.empty()) {
+ md += std::format("* Paid via: {}\n", o.paidVia);
+ }
+ md += "\n## Billed and shipped to\n\n";
+ md += std::format("{} \n{} \n{} {} \n{}\n\n",
+ o.buyer.name, o.buyer.street, o.buyer.postal,
+ o.buyer.city, o.buyer.country);
+
+ md += "## Amounts\n\n";
+ md += "| Description | Qty | Amount |\n|---|---|---|\n";
+ if (o.vatIncluded) {
+ // EU supply: net amounts per line, VAT once over the taxable total —
+ // the same line-total rounding the checkout charged with.
+ const std::int64_t net = Money::NetFromGross(o.totalMinor);
+ const std::int64_t vat = o.totalMinor - net;
+ md += std::format("| {} | {} | {} |\n", item, o.quantity,
+ Money::FormatEuro(Money::NetFromGross(o.goodsMinor)));
+ md += std::format("| Shipping | 1 | {} |\n",
+ Money::FormatEuro(Money::NetFromGross(o.shippingMinor)));
+ md += std::format("| Subtotal (ex VAT) | | {} |\n", Money::FormatEuro(net));
+ md += std::format("| VAT 21% (NL) | | {} |\n", Money::FormatEuro(vat));
+ md += std::format("| **Total (incl. VAT)** | | **{}** |\n",
+ Money::FormatEuro(o.totalMinor));
+ } else {
+ md += std::format("| {} | {} | {} |\n", item, o.quantity,
+ Money::FormatEuro(o.goodsMinor));
+ md += std::format("| Shipping | 1 | {} |\n",
+ Money::FormatEuro(o.shippingMinor));
+ md += std::format("| **Total** | | **{}** |\n",
+ Money::FormatEuro(o.totalMinor));
+ md += "\nVAT 0%: zero-rated export outside the EU "
+ "(art. 146 EU VAT Directive). Import duties and taxes are levied "
+ "by the destination country and are not part of this invoice.\n";
+ }
+
+ md += "\nThis invoice was generated by catcrafts.net and signed with the "
+ "shop's GPG key. Verify with: gpg --verify \n";
+ return md;
+}
+
+void ConfigureInvoicing(std::string gpgKeyId) {
+ // The key id ends up on a command line — constrain it to the alphabet a
+ // fingerprint or uid email actually needs, and refuse anything else.
+ for (const char c : gpgKeyId) {
+ const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
+ || (c >= '0' && c <= '9') || c == '@' || c == '.'
+ || c == '_' || c == '-' || c == '+';
+ if (!ok) {
+ std::println(std::cerr,
+ "invoice: refusing GPG key id with unexpected characters");
+ return;
+ }
+ }
+ gGpgKeyId = std::move(gpgKeyId);
+ if (!gGpgKeyId.empty()) {
+ std::println(std::cerr, "invoice: signing with GPG key '{}'", gGpgKeyId);
+ }
+}
+
+bool InvoiceSigningConfigured() { return !gGpgKeyId.empty(); }
+
+std::optional ClearsignInvoice(const std::string& markdown) {
+ if (gGpgKeyId.empty()) return std::nullopt;
+
+ std::error_code ec;
+ const std::filesystem::path dir =
+ std::filesystem::temp_directory_path(ec) / "catcrafts-invoice";
+ if (ec) return std::nullopt;
+ std::filesystem::create_directories(dir, ec);
+ std::filesystem::permissions(dir, std::filesystem::perms::owner_all, ec);
+
+ // Distinct per call so concurrent downloads cannot collide.
+ static std::atomic counter{1};
+ const std::uint64_t n = counter.fetch_add(1);
+ const std::filesystem::path in = dir / std::format("in-{}.md", n);
+ const std::filesystem::path out = dir / std::format("out-{}.md.asc", n);
+
+ {
+ std::ofstream f(in, std::ios::trunc | std::ios::binary);
+ if (!f) return std::nullopt;
+ f << markdown;
+ if (!f.flush()) return std::nullopt;
+ }
+
+ // --batch: never prompt (the service has no terminal). The key must be
+ // passphrase-free or preset in the agent — deploy/README.md covers it.
+ const std::string cmd = std::format(
+ "gpg --batch --yes --clearsign --local-user '{}' -o '{}' '{}' 2>/dev/null",
+ gGpgKeyId, out.string(), in.string());
+ const int rc = std::system(cmd.c_str());
+
+ std::string signedText;
+ if (rc == 0) {
+ std::ifstream f(out, std::ios::binary);
+ std::ostringstream buf;
+ buf << f.rdbuf();
+ signedText = buf.str();
+ } else {
+ std::println(std::cerr, "invoice: gpg clearsign failed (rc {})", rc);
+ }
+ std::filesystem::remove(in, ec);
+ std::filesystem::remove(out, ec);
+
+ if (signedText.empty()) return std::nullopt;
+ return signedText;
+}
+
+} // namespace Catcrafts::Server
diff --git a/server/implementations/Catcrafts.Server-Mollie.cpp b/server/implementations/Catcrafts.Server-Mollie.cpp
new file mode 100644
index 0000000..58fb245
--- /dev/null
+++ b/server/implementations/Catcrafts.Server-Mollie.cpp
@@ -0,0 +1,214 @@
+/*
+catcrafts.net
+Copyright (C) 2026 Catcrafts
+
+The source code of this website is made available for viewing purposes only.
+No permission is granted to copy, modify, distribute, or create derivative works.
+*/
+
+// The Mollie payment rail.
+//
+// Chosen over bunq.me after measuring bunq.me's limits (€500/transaction on
+// cards, no method for a non-EU buyer at phone prices — it is a P2P tool, not
+// a checkout). Mollie is a Dutch licensed PSP built for exactly this size of
+// shop: iDEAL at a flat per-transaction fee, cards behind SCA/3DS, and a
+// hosted checkout so card data never touches this server.
+//
+// The API is refreshingly small next to bunq's: one bearer-token key, no
+// RSA signing, no session dance.
+//
+// POST /v2/payments {amount, description, redirectUrl} -> id + checkout URL
+// GET /v2/payments/{id} -> status, method
+//
+// Trust direction is unchanged from the design rule: the ?redirect back to
+// the order page is ignored; an order becomes paid ONLY when an authenticated
+// GET says status=paid with a covering amount. One deliberate difference from
+// the bunq tab model: a Mollie payment can EXPIRE (canceled/expired/failed are
+// terminal), so the poll distinguishes Pending / Paid / Dead and the
+// reconciler lapses orders whose payment can never arrive.
+//
+// A test API key (test_…) works against the real endpoints from the moment a
+// Mollie account is created — verify with that before going live; unlike the
+// bunq client this one need not ship on faith.
+
+module;
+module Catcrafts.Server;
+
+import std;
+import Catcrafts.Shared;
+import Crafter.Network;
+
+using namespace Crafter;
+
+namespace Catcrafts::Server {
+
+namespace {
+
+std::string JsonEscapeM(std::string_view s) {
+ std::string out;
+ out.reserve(s.size() + 8);
+ for (const char c : s) {
+ switch (c) {
+ case '"': out += "\\\""; break;
+ case '\\': out += "\\\\"; break;
+ case '\n': out += "\\n"; break;
+ case '\r': out += "\\r"; break;
+ case '\t': out += "\\t"; break;
+ default:
+ if (static_cast(c) < 0x20) {
+ out += std::format("\\u{:04x}", static_cast(c));
+ } else {
+ out += c;
+ }
+ }
+ }
+ return out;
+}
+
+} // namespace
+
+std::optional ParseMolliePayment(std::string_view json) {
+ auto doc = Json::Parse(json);
+ if (!doc || !doc->IsObject()) return std::nullopt;
+
+ MolliePayment p;
+ p.id = std::string(doc->Str("id"));
+ p.status = std::string(doc->Str("status"));
+ p.method = std::string(doc->Str("method"));
+ if (p.id.empty() || p.status.empty()) return std::nullopt;
+
+ if (const Json::Value* amount = doc->Find("amount"); amount && amount->IsObject()) {
+ // Only euro amounts are ever created, so anything else failing to
+ // parse to zero is the safe outcome — a zero amount never satisfies
+ // an order total.
+ if (amount->Str("currency") == "EUR") {
+ if (auto minor = ParseAmountToMinor(amount->Str("value"))) {
+ p.amountMinor = *minor;
+ }
+ }
+ }
+ if (const Json::Value* links = doc->Find("_links"); links && links->IsObject()) {
+ if (const Json::Value* checkout = links->Find("checkout");
+ checkout && checkout->IsObject()) {
+ p.checkoutUrl = std::string(checkout->Str("href"));
+ }
+ }
+ return p;
+}
+
+namespace {
+
+class MollieRail final : public PaymentRail {
+public:
+ explicit MollieRail(RailConfig cfg) : cfg_(std::move(cfg)) {}
+
+ std::optional CreateLink(std::int64_t amountMinor,
+ const std::string& description,
+ const std::string& redirectUrl) override {
+ std::lock_guard lock(mutex_);
+ const std::string body = std::format(
+ R"({{"amount":{{"currency":"EUR","value":"{}"}},)"
+ R"("description":"{}","redirectUrl":"{}"}})",
+ Money::FormatMinor(amountMinor), JsonEscapeM(description),
+ JsonEscapeM(redirectUrl));
+
+ const std::optional res = Call("POST", "/v2/payments", body);
+ if (!res) return std::nullopt;
+ const auto payment = ParseMolliePayment(*res);
+ if (!payment || payment->checkoutUrl.empty()) {
+ std::println(std::cerr, "mollie: create returned no checkout url");
+ return std::nullopt;
+ }
+ PaymentLink link;
+ link.payId = payment->id;
+ link.payUrl = payment->checkoutUrl;
+ return link;
+ }
+
+ std::optional CheckPaid(const std::string& payId,
+ std::int64_t expectedMinor) override {
+ std::lock_guard lock(mutex_);
+ // The id came from Mollie, but it travels through our ledger — keep
+ // the path composition strict anyway.
+ for (const char c : payId) {
+ const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
+ || (c >= '0' && c <= '9') || c == '_';
+ if (!ok) return PaidStatus{ PayState::Dead, {} };
+ }
+
+ const std::optional res = Call("GET", "/v2/payments/" + payId, {});
+ if (!res) return std::nullopt;
+ const auto payment = ParseMolliePayment(*res);
+ if (!payment) return std::nullopt;
+
+ PaidStatus out;
+ out.method = payment->method;
+ if (payment->status == "paid" && payment->amountMinor >= expectedMinor) {
+ out.state = PayState::Paid;
+ } else if (payment->status == "canceled" || payment->status == "expired"
+ || payment->status == "failed") {
+ out.state = PayState::Dead;
+ } else {
+ // open / pending / authorized — still in flight.
+ out.state = PayState::Pending;
+ }
+ return out;
+ }
+
+ std::string_view Name() const override { return "mollie"; }
+ std::chrono::seconds PollInterval() const override { return std::chrono::seconds(10); }
+
+private:
+ // One HTTPS call; nullopt on transport failure or a non-2xx answer. The
+ // reconciler treats nullopt as "unknown, retry" — never as unpaid or dead.
+ std::optional Call(std::string_view method, const std::string& path,
+ const std::string& body) {
+ try {
+ if (!client_) {
+ client_ = std::make_unique(
+ "api.mollie.com", static_cast(443),
+ Crafter::TLSClientCredentials{});
+ }
+ Crafter::HTTPRequest req;
+ req.method = std::string(method);
+ req.path = path;
+ req.authority = "api.mollie.com";
+ req.body = body;
+ req.headers["authorization"] = "Bearer " + cfg_.apiKey;
+ req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
+ if (!body.empty()) req.headers["content-type"] = "application/json";
+
+ const Crafter::HTTPResponse res = client_->Send(req);
+ if (res.status.size() != 3 || res.status[0] != '2') {
+ std::println(std::cerr, "mollie: {} {} -> {} {}", method, path,
+ res.status, res.body.substr(0, 200));
+ return std::nullopt;
+ }
+ return res.body;
+ } catch (const std::exception& e) {
+ std::println(std::cerr, "mollie: {} {} failed: {}", method, path, e.what());
+ client_.reset(); // dial fresh next time
+ return std::nullopt;
+ }
+ }
+
+ RailConfig cfg_;
+ std::mutex mutex_;
+ std::unique_ptr client_;
+};
+
+} // namespace
+
+// Defined here rather than in the bunq unit so the rail roster has one home;
+// the bunq and fake constructors are declared by their own units.
+std::unique_ptr MakeBunqRail(const RailConfig& config);
+std::unique_ptr MakeFakeRail(const RailConfig& config);
+
+std::unique_ptr MakeRail(const RailConfig& config) {
+ if (config.mode == "fake") return MakeFakeRail(config);
+ if (config.mode == "mollie") return std::make_unique(config);
+ if (config.mode == "bunq") return MakeBunqRail(config);
+ return nullptr; // "off"
+}
+
+} // namespace Catcrafts::Server
diff --git a/server/implementations/Catcrafts.Server-Orders.cpp b/server/implementations/Catcrafts.Server-Orders.cpp
new file mode 100644
index 0000000..c5de901
--- /dev/null
+++ b/server/implementations/Catcrafts.Server-Orders.cpp
@@ -0,0 +1,301 @@
+/*
+catcrafts.net
+Copyright (C) 2026 Catcrafts
+
+The source code of this website is made available for viewing purposes only.
+No permission is granted to copy, modify, distribute, or create derivative works.
+*/
+
+// Order storage: an append-only JSON-lines event log.
+//
+// Two event types share the file:
+//
+// {"type":"order", ...full record...} written once, at checkout
+// {"type":"status", "id":..,"status":..} one per transition
+//
+// Current state is a left fold over the file; later events win. Nothing is
+// ever rewritten, so the log doubles as the audit trail the tax records need,
+// and a crash mid-write costs at most its own line (a truncated last line is
+// skipped by the reader, not fatal).
+//
+// Why not SQLite yet: single-digit orders per week, one writer, no relations.
+// The day volume proves that wrong, this imports into a database in one
+// sitting. What this file holds is personal data (name, address, email), so
+// the same rules as ever: 0600 via the service's umask, off the web root,
+// encrypted before any backup leaves the machine.
+
+module;
+module Catcrafts.Server;
+
+import std;
+import Catcrafts.Shared;
+
+namespace Catcrafts::Server {
+
+namespace {
+
+std::mutex gOrdersMutex;
+std::filesystem::path gOrdersPath;
+
+// Minimal JSON string escaping. Values were validated upstream, but they are
+// still user input, and a raw newline or quote would corrupt the
+// line-per-record format — silently truncating the data on the next read.
+std::string JsonEscape(std::string_view s) {
+ std::string out;
+ out.reserve(s.size() + 8);
+ for (const char c : s) {
+ switch (c) {
+ case '"': out += "\\\""; break;
+ case '\\': out += "\\\\"; break;
+ case '\n': out += "\\n"; break;
+ case '\r': out += "\\r"; break;
+ case '\t': out += "\\t"; break;
+ default:
+ if (static_cast(c) < 0x20) {
+ out += std::format("\\u{:04x}", static_cast(c));
+ } else {
+ out += c;
+ }
+ }
+ }
+ return out;
+}
+
+bool AppendLine(const std::string& line) {
+ if (gOrdersPath.empty()) return false;
+ // Open per append: orders arrive rarely, and a file reopened each time can
+ // be rotated or edited underneath the running process without a restart.
+ std::ofstream out(gOrdersPath, std::ios::app | std::ios::binary);
+ if (!out) return false;
+ out << line << '\n';
+ out.flush();
+ // Report the stream state: a full disk must surface as a visible error,
+ // not a payment link over an order that was never recorded.
+ return static_cast(out);
+}
+
+// Fold the whole log into id -> record. Corrupt lines are skipped — one bad
+// line must not take the rest of the ledger with it.
+std::vector FoldLocked() {
+ std::vector out;
+ if (gOrdersPath.empty()) return out;
+ std::ifstream in(gOrdersPath, std::ios::binary);
+ if (!in) return out;
+
+ auto find = [&](std::string_view token) -> OrderRecord* {
+ for (OrderRecord& r : out) {
+ if (r.token == token) return &r;
+ }
+ return nullptr;
+ };
+
+ std::string line;
+ while (std::getline(in, line)) {
+ auto doc = Json::Parse(line);
+ if (!doc || !doc->IsObject()) continue;
+ const std::string_view type = doc->Str("type");
+ if (type == "order") {
+ OrderRecord r;
+ r.token = std::string(doc->Str("id"));
+ r.reference = std::string(doc->Str("ref"));
+ r.product = std::string(doc->Str("product"));
+ r.color = std::string(doc->Str("color"));
+ r.quantity = doc->Int("quantity", 1);
+ r.unitMinor = doc->Int("unit_minor");
+ r.createdAt = std::string(doc->Str("at"));
+ r.updatedAt = r.createdAt;
+ r.buyer.email = std::string(doc->Str("email"));
+ r.buyer.name = std::string(doc->Str("name"));
+ r.buyer.street = std::string(doc->Str("street"));
+ r.buyer.postal = std::string(doc->Str("postal"));
+ r.buyer.city = std::string(doc->Str("city"));
+ r.buyer.country = std::string(doc->Str("country"));
+ r.goodsMinor = doc->Int("goods_minor");
+ r.shippingMinor = doc->Int("shipping_minor");
+ r.totalMinor = doc->Int("total_minor");
+ r.vatIncluded = doc->Bool("vat_included");
+ r.status = std::string(doc->Str("status", "awaiting_payment"));
+ r.payUrl = std::string(doc->Str("pay_url"));
+ r.payId = std::string(doc->Str("pay_id"));
+ if (r.token.empty()) continue;
+ // A duplicate "order" event for an id would be a writer bug; first
+ // one wins so a replayed line cannot rewrite history.
+ if (!find(r.token)) out.push_back(std::move(r));
+ } else if (type == "invoice") {
+ OrderRecord* r = find(doc->Str("id"));
+ if (!r) continue;
+ r->invoiceNumber = std::string(doc->Str("number"));
+ r->invoicedAt = std::string(doc->Str("at"));
+ } else if (type == "status") {
+ OrderRecord* r = find(doc->Str("id"));
+ if (!r) continue; // status for an unknown order: skip, keep folding
+ const std::string_view status = doc->Str("status");
+ if (status.empty()) continue;
+ r->status = std::string(status);
+ r->updatedAt = std::string(doc->Str("at"));
+ if (const std::string_view via = doc->Str("via"); !via.empty()) {
+ r->paidVia = std::string(via);
+ }
+ }
+ }
+ return out;
+}
+
+} // namespace
+
+void SetOrdersPath(const std::filesystem::path& p) {
+ std::lock_guard lock(gOrdersMutex);
+ gOrdersPath = p;
+}
+
+bool CreateOrder(const OrderRecord& o) {
+ std::lock_guard lock(gOrdersMutex);
+ return AppendLine(std::format(
+ R"({{"type":"order","at":"{}","id":"{}","ref":"{}","product":"{}",)"
+ R"("color":"{}","quantity":{},"unit_minor":{},)"
+ R"("email":"{}","name":"{}","street":"{}","postal":"{}","city":"{}","country":"{}",)"
+ R"("goods_minor":{},"shipping_minor":{},"total_minor":{},"vat_included":{},)"
+ R"("status":"{}","pay_url":"{}","pay_id":"{}"}})",
+ JsonEscape(o.createdAt), JsonEscape(o.token), JsonEscape(o.reference),
+ JsonEscape(o.product),
+ JsonEscape(o.color), o.quantity, o.unitMinor,
+ JsonEscape(o.buyer.email), JsonEscape(o.buyer.name), JsonEscape(o.buyer.street),
+ JsonEscape(o.buyer.postal), JsonEscape(o.buyer.city), JsonEscape(o.buyer.country),
+ o.goodsMinor, o.shippingMinor, o.totalMinor, o.vatIncluded,
+ JsonEscape(o.status), JsonEscape(o.payUrl), JsonEscape(o.payId)));
+}
+
+bool AppendOrderStatus(std::string_view token, std::string_view status,
+ std::string_view isoTimestamp, std::string_view via) {
+ std::lock_guard lock(gOrdersMutex);
+ if (via.empty()) {
+ return AppendLine(std::format(
+ R"({{"type":"status","at":"{}","id":"{}","status":"{}"}})",
+ JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(status)));
+ }
+ return AppendLine(std::format(
+ R"({{"type":"status","at":"{}","id":"{}","status":"{}","via":"{}"}})",
+ JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(status),
+ JsonEscape(via)));
+}
+
+namespace {
+
+// Case-normalised email: the customer key. Good enough on purpose — a person
+// with two addresses is two customers, exactly as they would be in the manual
+// administration this scheme continues.
+std::string CustomerKey(std::string_view email) {
+ std::string out(email);
+ for (char& c : out) {
+ if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a');
+ }
+ return out;
+}
+
+// A random v4 UUID — the customer number, matching the pre-shop invoice
+// administration (folders named by customer UUID, invoices -).
+std::string NewCustomerUuid() {
+ std::random_device rd;
+ std::array w{ rd(), rd(), rd(), rd() };
+ auto* b = reinterpret_cast(w.data());
+ b[6] = static_cast((b[6] & 0x0f) | 0x40); // version 4
+ b[8] = static_cast((b[8] & 0x3f) | 0x80); // variant 10
+ std::string out;
+ out.reserve(36);
+ static constexpr char hex[] = "0123456789abcdef";
+ for (int i = 0; i < 16; ++i) {
+ if (i == 4 || i == 6 || i == 8 || i == 10) out += '-';
+ out += hex[b[i] >> 4];
+ out += hex[b[i] & 0xf];
+ }
+ return out;
+}
+
+} // namespace
+
+std::optional AssignInvoiceNumber(std::string_view token,
+ std::string_view isoTimestamp) {
+ // Numbering continues the shop owner's existing administration: one
+ // SERIES PER CUSTOMER (a random UUID as customer number), sequential
+ // within it — "f57c6512-…-3" is that customer's third invoice. Multiple
+ // series are what art. 226(2)'s "one or more series" permits, and the
+ // append-only ledger plus the payment provider's records carry the
+ // completeness proof an auditor actually wants.
+ std::lock_guard lock(gOrdersMutex);
+ const OrderRecord* target = nullptr;
+ std::vector all = FoldLocked();
+ for (const OrderRecord& r : all) {
+ if (r.token == token) { target = &r; break; }
+ }
+ if (!target) return std::nullopt;
+ // Idempotent: a paid order re-processed (manual CLI after the reconciler,
+ // say) keeps its number — a sequence never burns a member on a retry.
+ if (!target->invoiceNumber.empty()) return target->invoiceNumber;
+
+ // The customer's existing series, if any: same email (case-normalised),
+ // highest sequence. Invoice numbers are "-".
+ const std::string key = CustomerKey(target->buyer.email);
+ std::string customer;
+ std::int64_t maxSeq = 0;
+ for (const OrderRecord& r : all) {
+ if (r.invoiceNumber.size() < 38 || CustomerKey(r.buyer.email) != key) continue;
+ customer = r.invoiceNumber.substr(0, 36);
+ std::int64_t seq = 0;
+ const char* b = r.invoiceNumber.data() + 37;
+ std::from_chars(b, r.invoiceNumber.data() + r.invoiceNumber.size(), seq);
+ maxSeq = std::max(maxSeq, seq);
+ }
+ if (customer.empty()) customer = NewCustomerUuid();
+
+ const std::string number = std::format("{}-{}", customer, maxSeq + 1);
+ if (!AppendLine(std::format(
+ R"({{"type":"invoice","at":"{}","id":"{}","number":"{}","customer":"{}"}})",
+ JsonEscape(isoTimestamp), JsonEscape(token), JsonEscape(number),
+ JsonEscape(customer)))) {
+ return std::nullopt;
+ }
+ return number;
+}
+
+std::optional FindOrder(std::string_view token) {
+ std::lock_guard lock(gOrdersMutex);
+ for (OrderRecord& r : FoldLocked()) {
+ if (r.token == token) return std::move(r);
+ }
+ return std::nullopt;
+}
+
+std::vector ListOrders() {
+ std::lock_guard lock(gOrdersMutex);
+ return FoldLocked();
+}
+
+std::string NewOrderToken() {
+ // std::random_device on this platform reads the kernel CSPRNG. The token
+ // gates access to a name and address, so 128 bits — the same order of
+ // unguessability as a session cookie.
+ std::random_device rd;
+ std::string out;
+ out.reserve(32);
+ for (int i = 0; i < 4; ++i) {
+ const std::uint32_t w = rd();
+ out += std::format("{:08x}", w);
+ }
+ return out;
+}
+
+std::string ReferenceFromToken(std::string_view token) {
+ // Derived, not random: an order can never carry a mismatched pair. Six hex
+ // chars is what a human will actually type into a transfer description; at
+ // this volume a collision is a curiosity, and the amount+time still
+ // disambiguate at reconciliation.
+ std::string out = "CC-";
+ for (std::size_t i = 0; i < 6 && i < token.size(); ++i) {
+ char c = token[i];
+ if (c >= 'a' && c <= 'z') c = static_cast(c - 'a' + 'A');
+ out += c;
+ }
+ return out;
+}
+
+} // namespace Catcrafts::Server
diff --git a/server/implementations/Catcrafts.Server-Shipping.cpp b/server/implementations/Catcrafts.Server-Shipping.cpp
new file mode 100644
index 0000000..f77efc5
--- /dev/null
+++ b/server/implementations/Catcrafts.Server-Shipping.cpp
@@ -0,0 +1,258 @@
+/*
+catcrafts.net
+Copyright (C) 2026 Catcrafts
+
+The source code of this website is made available for viewing purposes only.
+No permission is granted to copy, modify, distribute, or create derivative works.
+*/
+
+// Live shipping rates from Sendcloud, with the zone table as the floor.
+//
+// Shape: GET /api/v2/shipping_methods (basic auth) returns every method the
+// account can book, each with a per-country price list. One configured method
+// (matched by name substring) becomes a country -> cents table, cached to disk
+// and refreshed daily by a background thread the HTTP layer starts.
+//
+// Failure posture mirrors the rest of the build pipeline: Sendcloud being
+// down, slow, or unconfigured NEVER breaks checkout — the compiled-in zone
+// table (Catcrafts.Shared:Content) answers instead. A stale cached table
+// beats both, which is why the cache survives restarts.
+//
+// Like the bunq rail, this code has not run against the real API — no
+// credentials existed at build time. ParseSendcloudMethods is exercised by the
+// self-test against a canned response; the fetch around it is thin.
+
+module;
+module Catcrafts.Server;
+
+import std;
+import Catcrafts.Shared;
+import Crafter.Network;
+
+using namespace Crafter;
+
+namespace Catcrafts::Server {
+
+namespace {
+
+std::mutex gShipMutex;
+ShippingConfig gShipConfig;
+ShippingTable gShipTable;
+bool gShipConfigured = false;
+
+// Same alphabet as the bunq helper; duplicated rather than shared because
+// each implementation unit keeps its internals to itself.
+std::string Base64S(std::string_view in) {
+ static constexpr char tbl[] =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ std::string out;
+ std::size_t i = 0;
+ const auto* d = reinterpret_cast(in.data());
+ for (; i + 2 < in.size(); i += 3) {
+ const std::uint32_t n = (d[i] << 16) | (d[i + 1] << 8) | d[i + 2];
+ out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
+ out += tbl[(n >> 6) & 63]; out += tbl[n & 63];
+ }
+ if (i + 1 == in.size()) {
+ const std::uint32_t n = d[i] << 16;
+ out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63]; out += "==";
+ } else if (i + 2 == in.size()) {
+ const std::uint32_t n = (d[i] << 16) | (d[i + 1] << 8);
+ out += tbl[(n >> 18) & 63]; out += tbl[(n >> 12) & 63];
+ out += tbl[(n >> 6) & 63]; out += '=';
+ }
+ return out;
+}
+
+std::string NowIsoS() {
+ return std::format("{:%FT%TZ}", std::chrono::floor(
+ std::chrono::system_clock::now()));
+}
+
+void SaveCacheLocked() {
+ if (gShipConfig.cachePath.empty()) return;
+ std::ofstream out(gShipConfig.cachePath, std::ios::trunc | std::ios::binary);
+ if (!out) return;
+ out << std::format(R"({{"method":"{}","fetched_at":"{}","per_country":{{)",
+ gShipTable.method, gShipTable.fetchedAt);
+ bool first = true;
+ for (const auto& [cc, minor] : gShipTable.perCountry) {
+ out << std::format(R"({}"{}":{})", first ? "" : ",", cc, minor);
+ first = false;
+ }
+ out << "}}\n";
+}
+
+void LoadCacheLocked() {
+ std::ifstream in(gShipConfig.cachePath, std::ios::binary);
+ if (!in) return;
+ std::ostringstream buf;
+ buf << in.rdbuf();
+ auto doc = Json::Parse(buf.str());
+ if (!doc || !doc->IsObject()) return;
+ ShippingTable t;
+ t.method = std::string(doc->Str("method"));
+ t.fetchedAt = std::string(doc->Str("fetched_at"));
+ if (const Json::Value* m = doc->Find("per_country"); m && m->IsObject()) {
+ for (const auto& [k, v] : m->object) {
+ if (v.type == Json::Type::Number && v.number > 0) {
+ t.perCountry.emplace_back(k, static_cast(v.number));
+ }
+ }
+ }
+ if (!t.perCountry.empty()) gShipTable = std::move(t);
+}
+
+} // namespace
+
+// `methodName` is a comma-separated list of name substrings, merged in order
+// with FIRST MATCH PER COUNTRY winning. One method rarely covers a whole
+// market: the realistic setup is a courier inside Europe and post beyond
+// ("DPD Home,PostNL Parcels non-EU"), and the order encodes the preference —
+// a country served by both gets the earlier method's price.
+ShippingTable ParseSendcloudMethods(std::string_view json, std::string_view methodName) {
+ ShippingTable out;
+ auto doc = Json::Parse(json);
+ if (!doc || !doc->IsObject()) return out;
+ const Json::Value* methods = doc->Find("shipping_methods");
+ if (!methods || !methods->IsArray()) return out;
+
+ std::vector filters;
+ {
+ std::string_view rest = methodName;
+ while (!rest.empty()) {
+ const std::size_t comma = rest.find(',');
+ std::string_view part = rest.substr(0, comma);
+ while (!part.empty() && part.front() == ' ') part.remove_prefix(1);
+ while (!part.empty() && part.back() == ' ') part.remove_suffix(1);
+ if (!part.empty()) filters.push_back(part);
+ if (comma == std::string_view::npos) break;
+ rest = rest.substr(comma + 1);
+ }
+ }
+
+ for (const std::string_view filter : filters) {
+ for (const Json::Value& method : methods->array) {
+ if (!method.IsObject()) continue;
+ const std::string_view name = method.Str("name");
+ if (name.find(filter) == std::string_view::npos) continue;
+
+ if (!out.method.empty()) out.method += " + ";
+ out.method += std::string(name);
+ if (const Json::Value* countries = method.Find("countries");
+ countries && countries->IsArray()) {
+ for (const Json::Value& c : countries->array) {
+ if (!c.IsObject()) continue;
+ std::string cc(c.Str("iso_2"));
+ if (cc.size() != 2) continue;
+ // Earlier methods own their countries — a later method
+ // never overrides.
+ if (out.Find(cc) > 0) continue;
+ // Sendcloud sends the price as a JSON number of euros.
+ // Money stays integer everywhere else; this one boundary
+ // rounds a decimal that is exact to the cent in a double
+ // (shipping prices are far inside the safe range), and
+ // llround guards the representation edge
+ // (8.20*100 == 819.999...).
+ const Json::Value* price = c.Find("price");
+ if (!price || price->type != Json::Type::Number) continue;
+ const std::int64_t minor = std::llround(price->number * 100.0);
+ if (minor <= 0) continue;
+ out.perCountry.emplace_back(std::move(cc), minor);
+ }
+ }
+ break; // first method matching THIS filter wins; next filter
+ }
+ }
+ return out;
+}
+
+void ConfigureShipping(const ShippingConfig& config) {
+ std::lock_guard lock(gShipMutex);
+ gShipConfig = config;
+ gShipConfigured = !config.publicKey.empty() && !config.secretKey.empty()
+ && !config.methodName.empty();
+ LoadCacheLocked();
+ if (gShipConfigured) {
+ std::println(std::cerr,
+ "shipping: sendcloud configured (method filter '{}'){}",
+ config.methodName,
+ gShipTable.perCountry.empty()
+ ? ""
+ : std::format(", cached table: {} countries from {}",
+ gShipTable.perCountry.size(),
+ gShipTable.fetchedAt));
+ }
+}
+
+std::int64_t ShipCostFor(std::string_view country, std::int64_t zoneNl,
+ std::int64_t zoneEu, std::int64_t zoneWorld) {
+ {
+ std::lock_guard lock(gShipMutex);
+ if (const std::int64_t live = gShipTable.Find(country); live > 0) return live;
+ }
+ return Money::ZoneShipping(zoneNl, zoneEu, zoneWorld, country);
+}
+
+ShippingTable CurrentShippingTable() {
+ std::lock_guard lock(gShipMutex);
+ return gShipTable;
+}
+
+// Called by the HTTP layer's refresh thread. One authenticated GET; on any
+// failure the previous table (cached or zone fallback) simply stays.
+void RefreshShippingTable() {
+ ShippingConfig cfg;
+ {
+ std::lock_guard lock(gShipMutex);
+ if (!gShipConfigured) return;
+ cfg = gShipConfig;
+ }
+
+ try {
+ Crafter::ClientHTTP1 client("panel.sendcloud.sc",
+ static_cast(443),
+ Crafter::TLSClientCredentials{});
+ Crafter::HTTPRequest req;
+ req.method = "GET";
+ req.path = "/api/v2/shipping_methods";
+ req.authority = "panel.sendcloud.sc";
+ req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
+ req.headers["authorization"] =
+ "Basic " + Base64S(cfg.publicKey + ":" + cfg.secretKey);
+ const Crafter::HTTPResponse res = client.Send(req);
+ if (res.status != "200") {
+ std::println(std::cerr, "shipping: sendcloud answered {}", res.status);
+ return;
+ }
+ ShippingTable t = ParseSendcloudMethods(res.body, cfg.methodName);
+ if (t.perCountry.empty()) {
+ std::println(std::cerr,
+ "shipping: no method matching '{}' with prices in the response",
+ cfg.methodName);
+ return;
+ }
+ // Sendcloud rates are the shop's ex-VAT COST. What the buyer is
+ // charged must NET that cost: EU destinations are grossed up by the
+ // VAT rate here (€7.13 -> €8.63; the difference is remitted, the cost
+ // is covered), non-EU postage is zero-rated so cost is charged as-is.
+ // Done once at table build — the cache stores consumer prices, so a
+ // cache reload must not (and does not) gross up again.
+ for (auto& [cc, minor] : t.perCountry) {
+ if (Money::IsEuCountry(cc)) minor = Money::GrossFromNet(minor);
+ }
+ t.fetchedAt = NowIsoS();
+ {
+ std::lock_guard lock(gShipMutex);
+ gShipTable = std::move(t);
+ SaveCacheLocked();
+ }
+ std::println(std::cerr, "shipping: table refreshed ({} countries, method '{}')",
+ CurrentShippingTable().perCountry.size(),
+ CurrentShippingTable().method);
+ } catch (const std::exception& e) {
+ std::println(std::cerr, "shipping: refresh failed: {}", e.what());
+ }
+}
+
+} // namespace Catcrafts::Server
diff --git a/server/implementations/main.cpp b/server/implementations/main.cpp
new file mode 100644
index 0000000..061a7ec
--- /dev/null
+++ b/server/implementations/main.cpp
@@ -0,0 +1,932 @@
+/*
+catcrafts.net
+Copyright (C) 2026 Catcrafts
+
+The source code of this website is made available for viewing purposes only.
+No permission is granted to copy, modify, distribute, or create derivative works.
+*/
+
+// catcrafts-server — the native product.
+//
+// Serves the server-rendered pages (crawlers and no-JS clients get real HTML),
+// runs the shop — orders, the bunq payment rail, the reconciler — and doubles
+// as the test harness for Catcrafts.Shared.
+//
+// The harness half is not filler. Catcrafts.Shared is the security boundary
+// for every piece of markup the site emits, and it is target-neutral precisely
+// so it can be tested somewhere with a debugger, sanitizers and a normal test
+// loop instead of only inside a wasm module in a browser tab. `--selftest`
+// is how the shared code gets executed rather than merely compiled.
+//
+// crafter-build -- --product=server && ./bin/Catcrafts.Server-*/catcrafts-server --selftest
+
+import std;
+import Catcrafts.Shared;
+import Catcrafts.Server;
+
+using namespace Catcrafts;
+
+namespace {
+
+int failures = 0;
+
+void Check(bool ok, std::string_view what, std::string_view got = {}) {
+ if (ok) return;
+ ++failures;
+ std::println(std::cerr, "FAIL: {}{}{}", what,
+ got.empty() ? "" : " got: ", got);
+}
+
+void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
+ Check(actual.View() == expected, what, actual.View());
+}
+
+void RunSelfTest() {
+ using namespace Catcrafts::Html;
+
+ // ── Escape ────────────────────────────────────────────────────────
+ CheckEq(Escape("plain"), "plain", "escape: passthrough");
+ CheckEq(Escape("ab"), "a>b", "escape: gt");
+ CheckEq(Escape("a&b"), "a&b", "escape: amp");
+ CheckEq(Escape("say \"hi\""), "say "hi"", "escape: dquote");
+ CheckEq(Escape("it's"), "it's", "escape: squote");
+ // Ampersand must be escaped first or the other replacements get
+ // double-encoded; a single pass makes that ordering bug impossible.
+ CheckEq(Escape("<"), "<", "escape: no double-encode");
+ CheckEq(Escape(""),
+ "<script>alert(1)</script>", "escape: script tag");
+ // Non-ASCII passes through untouched — the output is UTF-8, and
+ // entity-encoding it would just bloat the page.
+ CheckEq(Escape("café ✓ 日本"), "café ✓ 日本", "escape: utf-8 passthrough");
+ CheckEq(Escape(""), "", "escape: empty");
+
+ // ── Num ───────────────────────────────────────────────────────────
+ CheckEq(Num(0), "0", "num: zero");
+ CheckEq(Num(-42), "-42", "num: negative");
+ CheckEq(Num(9007199254740993LL), "9007199254740993", "num: beyond double precision");
+
+ // ── Attr ──────────────────────────────────────────────────────────
+ CheckEq(Attr("class", "card"), " class=\"card\"", "attr: basic");
+ CheckEq(Attr("data-x", "a\"b"), " data-x=\"a"b\"", "attr: value escaped");
+ CheckEq(Attr("class", ""), "", "attr: empty value omits attribute");
+ // An invalid name is a programming error, not user data. Emitting
+ // nothing is safer than emitting mangled markup.
+ CheckEq(Attr("on error", "x"), "", "attr: invalid name rejected");
+ CheckEq(Attr("x>\n";
+
+// Wraps a rendered page in a complete HTML document.
+//
+// `bootScripts` is the ' | grep -qE 'fetch|XMLHttpRequest|WebSocket|navigator\.sendBeacon'; then
+ bad "$pg script" "the price hint makes network calls"
+ else
+ ok "$pg script makes no network calls"
+ fi
+done
+body_has /shop/fp6-pmos 'cc-noneu' "price hint tags the non-EU outcome"
+body_has /shop/fp6-pmos 'cc-eu' "price hint tags the confirmed-EU outcome too"
+
+# The shop card: one euro number as the crawler/no-JS text, every supported
+# currency pre-formatted server-side as a data attribute for the script to
+# pick from. Converted amounts carry "~". CAD converts the ex-VAT price;
+# SEK (an EU member's currency) converts the VAT-inclusive price.
+body_has /shop 'class="price__single"' "shop card renders the single-number price"
+body_has /shop 'data-cad="~CA$' "shop card carries a CAD conversion"
+body_has /shop 'data-sek="~kr ' "shop card carries an SEK conversion"
+body_has /shop 'data-world="€465.54"' "shop card carries the euro export fallback"
+# The product page gets the same headline element, so a Canadian sees ~CA$
+# at the top there too, and the buy card states the customs position plainly.
+body_has /shop/fp6-pmos 'data-cad="~CA$' "product page headline carries the conversion"
+body_has /shop/fp6-pmos 'indicative only' "buy card says converted prices are indicative"
+body_has /shop/fp6-pmos 'customs authority' "buy card names whose problem import charges are"
+body_lacks /shop/fp6-pmos 'collected on arrival' "the vague customs phrasing is gone"
+# The label must not claim the Dutch rate is an EU-wide one.
+body_lacks /shop/fp6-pmos 'EU VAT' "price label does not call 21% an EU-wide rate"
+# The renderer loads only where a demo entry declares needsWasm — the demo LIST
+# is a content page and must stay free of it.
+body_has /demos/raytracer "catcrafts.wasm" "/demos/raytracer loads the wasm"
+body_lacks /demos "