/* 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("<"), "&lt;", "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>"), " href=\"#\"", "url: data: neutralised"); // Browsers strip control characters before resolving the scheme, so a // naive prefix check would pass this straight through. CheckEq(Url("href", "java\tscript:alert(1)"), " href=\"#\"", "url: embedded tab"); CheckEq(Url("href", " javascript:alert(1)"), " href=\"#\"", "url: leading space"); CheckEq(Url("href", "//evil.example/x"), " href=\"#\"", "url: protocol-relative blocked"); CheckEq(Url("href", "vbscript:x"), " href=\"#\"", "url: vbscript neutralised"); // ── Format ──────────────────────────────────────────────────────── // The compile-time half of this guarantee (raw std::string rejected) is // verified by the build itself — see the negative test in the notes. CheckEq(Format("

{}

", Escape("aa<b", "format: escapes flow through"); CheckEq(Format("{}", Url("href", "/x"), Escape("go")), "go", "format: attr + text"); CheckEq(Format("{}{}", Num(1), Num(2)), "12", "format: multiple args"); CheckEq(Format("literal"), "literal", "format: no args"); CheckEq(Format("{{literal braces}}"), "{literal braces}", "format: brace escaping"); // ── Join / concat ───────────────────────────────────────────────── const std::array parts{ Escape("a"), Escape("b"), Escape("c") }; CheckEq(Join(parts, Raw(", ")), "a, b, c", "join: separator"); CheckEq(Join(std::span{}), "", "join: empty"); CheckEq(Escape("a") + Escape("<"), "a<", "operator+: escapes preserved"); } void RunJsonSelfTest() { using namespace Catcrafts::Json; auto ok = [](std::string_view text) { return Parse(text).has_value(); }; auto bad = [](std::string_view text) { return !Parse(text).has_value(); }; // ── shapes ──────────────────────────────────────────────────────── Check(ok("{}"), "json: empty object"); Check(ok("[]"), "json: empty array"); Check(ok(" \n\t {\"a\": 1} \n "), "json: surrounding whitespace"); Check(ok("[1,2,3]"), "json: number array"); Check(ok("{\"a\":{\"b\":[true,false,null]}}"), "json: nesting"); // ── malformed input must be rejected, not partially accepted ────── Check(bad("{"), "json: unterminated object"); Check(bad("[1,]"), "json: trailing comma"); Check(bad("{\"a\":1,}"), "json: trailing comma in object"); Check(bad("{'a':1}"), "json: single quotes"); Check(bad("\"unterminated"), "json: unterminated string"); Check(bad("{\"a\" 1}"), "json: missing colon"); Check(bad("nul"), "json: bad literal"); Check(bad("{} garbage"), "json: trailing content rejected"); Check(bad("[1,2] [3]"), "json: concatenated documents rejected"); Check(bad("\"raw\nnewline\""), "json: control char in string"); Check(bad("01"), "json: leading zero"); Check(bad("+1"), "json: leading plus"); Check(bad("1."), "json: trailing decimal point"); Check(bad(".5"), "json: bare fraction"); Check(bad("1e"), "json: empty exponent"); Check(bad("1e+"), "json: exponent sign with no digits"); Check(bad("-"), "json: lone minus"); Check(bad("1e400"), "json: out of double range"); Check(ok("0"), "json: zero"); Check(ok("-0"), "json: negative zero"); Check(ok("0.5"), "json: leading zero with fraction"); Check(ok("-1.5e-3"), "json: full number grammar"); Check(ok("1E+2"), "json: capital exponent"); Check(bad(""), "json: empty input"); // ── string decoding ─────────────────────────────────────────────── auto strOf = [](std::string_view doc) -> std::string { auto v = Parse(doc); if (!v || !v->IsObject()) return ""; return std::string(v->Str("k")); }; Check(strOf(R"({"k":"a\"b"})") == "a\"b", "json: escaped quote"); Check(strOf(R"({"k":"a\\b"})") == "a\\b", "json: escaped backslash"); Check(strOf(R"({"k":"a\nb"})") == "a\nb", "json: newline escape"); Check(strOf(R"({"k":"A"})") == "A", "json: \\u ascii"); Check(strOf(R"({"k":"é"})") == "é", "json: \\u latin-1"); Check(strOf(R"({"k":"日"})") == "日", "json: \\u BMP"); // Astral plane arrives as a UTF-16 surrogate pair. Encoding each half // separately yields invalid UTF-8 — emoji in Lemmy post titles are // exactly this case, so it has to be combined. Check(strOf(R"({"k":"😺"})") == "\U0001F63A", "json: surrogate pair -> emoji"); Check(strOf(R"({"k":"\ud83d"})") == "�", "json: lone high surrogate -> U+FFFD"); Check(strOf(R"({"k":"\ude3a"})") == "�", "json: lone low surrogate -> U+FFFD"); Check(strOf(R"({"k":"raw é ✓"})") == "raw é ✓", "json: raw utf-8 passthrough"); // ── accessors ───────────────────────────────────────────────────── auto doc = Parse(R"({"s":"x","n":42,"neg":-7,"b":true,"nul":null})"); Check(doc.has_value(), "json: accessor doc parses"); if (doc) { Check(doc->Str("s") == "x", "json: Str"); Check(doc->Int("n") == 42, "json: Int"); Check(doc->Int("neg") == -7, "json: Int negative"); Check(doc->Bool("b"), "json: Bool"); Check(doc->Str("missing", "fallback") == "fallback", "json: Str fallback"); Check(doc->Int("missing", 99) == 99, "json: Int fallback"); // Wrong-typed field falls back rather than reinterpreting. Check(doc->Int("s", 5) == 5, "json: type mismatch falls back"); Check(doc->Find("missing") == nullptr, "json: Find absent"); Check(doc->Find("nul") != nullptr && doc->Find("nul")->IsNull(), "json: present-null distinguishable from absent"); } // ── depth guard ─────────────────────────────────────────────────── std::string deep(200, '['); Check(bad(deep), "json: deep nesting rejected, not stack overflow"); } void RunFormSelfTest() { using namespace Catcrafts::Form; // ── urlencoded parsing ──────────────────────────────────────────── auto parse = [](std::string_view b) { return ParseUrlEncoded(b); }; auto f = parse("email=a%40b.example&country=NL"); Check(f.has_value(), "form: basic body parses"); if (f) { Check(f->Get("email") == "a@b.example", "form: %40 decodes to @"); Check(f->Get("country") == "NL", "form: second field"); Check(f->Get("missing").empty(), "form: absent field is empty"); Check(!f->Has("missing"), "form: Has() distinguishes absent"); } Check(parse("a=1&&b=2")->Size() == 2, "form: empty segment tolerated"); Check(parse("a=1&")->Size() == 1, "form: trailing & tolerated"); Check(parse("flag")->Has("flag"), "form: valueless key present"); Check(parse("")->Size() == 0, "form: empty body"); Check(parse("q=hello+world")->Get("q") == "hello world", "form: + is space"); Check(parse("q=a%2Bb")->Get("q") == "a+b", "form: %2B is a literal plus"); Check(parse("n=caf%C3%A9")->Get("n") == "café", "form: utf-8 percent-decoding"); Check(parse("n=100%")->Get("n") == "100%", "form: malformed escape passes through"); Check(parse("n=%zz")->Get("n") == "%zz", "form: non-hex escape passes through"); // A field name is not allowed to be empty — "=x" is malformed, not a field. Check(!parse("=x").has_value(), "form: empty field name rejected"); // Oversized input must be refused outright rather than truncated: acting on // half a form is worse than refusing it. Check(!parse(std::string(kMaxBodyBytes + 1, 'a')).has_value(), "form: oversized body rejected"); Check(!parse("a=" + std::string(kMaxFieldBytes + 1, 'x')).has_value(), "form: oversized field rejected"); // ── email shape ─────────────────────────────────────────────────── Check(LooksLikeEmail("a@b.example"), "email: minimal"); Check(LooksLikeEmail("first.last+tag@sub.domain.example"), "email: tagged, subdomain"); Check(!LooksLikeEmail("no-at-sign"), "email: no @"); Check(!LooksLikeEmail("@domain.example"), "email: empty local part"); Check(!LooksLikeEmail("user@"), "email: empty domain"); Check(!LooksLikeEmail("a@b@c.example"), "email: two @"); Check(!LooksLikeEmail("user@dotless"), "email: dotless domain"); Check(!LooksLikeEmail("user@.example"), "email: domain starts with dot"); Check(!LooksLikeEmail("a b@c.example"), "email: embedded space"); // Header-injection characters must never survive into anything that later // builds an email envelope. Check(!LooksLikeEmail("a@b.example\nBcc: x@y.example"), "email: newline rejected"); Check(!LooksLikeEmail("a@b.example\r\nSubject: x"), "email: CRLF rejected"); Check(!LooksLikeEmail("a,b@c.example"), "email: comma rejected"); Check(!LooksLikeEmail(""), "email: angle brackets rejected"); Check(!LooksLikeEmail(std::string(250, 'a') + "@b.example"), "email: over 254 chars rejected"); // ── country code ────────────────────────────────────────────────── Check(LooksLikeCountryCode("NL"), "country: uppercase"); Check(LooksLikeCountryCode("ca"), "country: lowercase accepted"); Check(!LooksLikeCountryCode("NLD"), "country: three letters rejected"); Check(!LooksLikeCountryCode("N"), "country: one letter rejected"); Check(!LooksLikeCountryCode("N1"), "country: digit rejected"); Check(!LooksLikeCountryCode(""), "country: empty rejected"); Check(Upper("nl") == "NL", "country: normalised to upper"); // ── trimming ────────────────────────────────────────────────────── Check(Trim(" x ") == "x", "trim: spaces"); Check(Trim("\t\r\nx\n") == "x", "trim: tabs and newlines"); Check(Trim(" ").empty(), "trim: all whitespace"); // ── checkout validation ─────────────────────────────────────────── constexpr std::string_view kGoodOrder = "email=a%40b.example&name=Ada&street=Main%20St%201&postal=1234AB&city=Delft&country=nl"; auto validate = [](std::string_view body) { return ValidateCheckout(*ParseUrlEncoded(body)); }; auto good = validate(kGoodOrder); Check(good.Ok(), "checkout: valid submission accepted"); Check(good.value.country == "NL", "checkout: country uppercased"); Check(good.value.street == "Main St 1", "checkout: street decoded and kept"); Check(!validate("name=Ada&street=x&postal=1&city=y&country=NL").Ok(), "checkout: missing email rejected"); Check(!validate("email=a%40b.example&street=x&postal=1&city=y&country=NL").Ok(), "checkout: missing name rejected"); Check(!validate("email=a%40b.example&name=Ada&postal=1&city=y&country=NL").Ok(), "checkout: missing street rejected"); Check(!validate("email=a%40b.example&name=Ada&street=x&city=y&country=NL").Ok(), "checkout: missing postal rejected"); Check(!validate("email=a%40b.example&name=Ada&street=x&postal=1&country=NL").Ok(), "checkout: missing city rejected"); Check(!validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y").Ok(), "checkout: missing country rejected"); Check(!validate("email=nonsense&name=Ada&street=x&postal=1&city=y&country=NL").Ok(), "checkout: bad email rejected"); // Every problem is reported at once — a form that surfaces one error per // submission makes people resubmit to discover the rest. Check(validate("email=&name=&street=&postal=&city=&country=").errors.size() == 6, "checkout: errors accumulate"); // Honeypot: a filled hidden field means a bot. The message must not name // the trap, or it teaches the next one how to pass. auto pot = validate(std::string(kGoodOrder) + "&website=http%3A%2F%2Fspam"); Check(!pot.Ok(), "checkout: honeypot rejects"); Check(pot.errors.size() == 1 && pot.errors[0].message.find("honeypot") == std::string::npos && pot.errors[0].message.find("website") == std::string::npos, "checkout: honeypot failure does not name the trap"); Check(!validate("email=a%40b.example&name=" + std::string(200, 'x') + "&street=x&postal=1&city=y&country=NL").Ok(), "checkout: overlong name rejected"); // Colour and quantity: shape checks here, catalogue checks in the handler. Check(validate(std::string(kGoodOrder) + "&color=green&quantity=2").Ok(), "checkout: colour and quantity accepted"); Check(validate(std::string(kGoodOrder) + "&quantity=2").value.quantity == 2, "checkout: quantity parsed"); Check(validate(kGoodOrder).value.quantity == 1, "checkout: quantity defaults to 1"); Check(!validate(std::string(kGoodOrder) + "&quantity=0").Ok(), "checkout: zero quantity rejected"); Check(validate(std::string(kGoodOrder) + "&quantity=9").Ok(), "checkout: bulk quantity welcome"); Check(validate(std::string(kGoodOrder) + "&quantity=99").Ok(), "checkout: the technical ceiling itself is fine"); Check(!validate(std::string(kGoodOrder) + "&quantity=100").Ok(), "checkout: past the technical ceiling rejected"); Check(!validate(std::string(kGoodOrder) + "&quantity=two").Ok(), "checkout: non-numeric quantity rejected"); Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(), "checkout: oversized colour rejected"); // A rejected field must still come back, or the visitor has to retype the // one thing they got wrong — the fastest way to lose a submission. auto rejected = validate("email=notanemail&name=Ada&street=Main%201&postal=1&city=y&country=NLD"); Check(!rejected.Ok(), "checkout: invalid pair rejected"); Check(rejected.value.email == "notanemail", "checkout: invalid email echoed back"); Check(rejected.value.country == "NLD", "checkout: invalid country echoed back as typed"); Check(rejected.value.name == "Ada", "checkout: valid sibling field preserved"); } void RunMoneySelfTest() { using namespace Catcrafts::Money; // ── formatting ──────────────────────────────────────────────────── Check(FormatMinor(58000) == "580.00", "money: wire format"); Check(FormatMinor(47934) == "479.34", "money: wire format with cents"); Check(FormatMinor(5) == "0.05", "money: sub-unit"); Check(FormatMinor(0) == "0.00", "money: zero"); Check(FormatEuro(58000) == "€580", "money: whole euros displayed bare"); Check(FormatEuro(47934) == "€479.34", "money: cents displayed when present"); // ── VAT arithmetic ──────────────────────────────────────────────── // €580.00 gross at 21%: net = 58000/1.21 = 47933.88... -> 47934 half-up. Check(NetFromGross(58000) == 47934, "vat: net from €580 gross"); // The derived pair must reconstruct plausibly: net + vat == gross. Check(58000 - NetFromGross(58000) == 10066, "vat: vat portion exact"); Check(NetFromGross(0) == 0, "vat: zero"); Check(NetFromGross(121) == 100, "vat: €1.21 -> €1.00 exactly"); // The gross-up direction, used to charge carrier costs without eating // the VAT slice: €7.13 cost -> €8.63 charged, and the pair round-trips. Check(GrossFromNet(713) == 863, "vat: gross from €7.13 net"); Check(NetFromGross(GrossFromNet(713)) == 713, "vat: gross-up round-trips"); Check(GrossFromNet(100) == 121, "vat: €1.00 -> €1.21 exactly"); Check(GrossFromNet(0) == 0, "vat: gross-up zero"); // ── zones and membership ────────────────────────────────────────── Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members"); Check(!IsEuCountry("GB"), "eu: UK left"); Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU"); Check(!IsEuCountry("CA") && !IsEuCountry("US"), "eu: north america"); Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)"); Check(ZoneFor("NL") == Zone::Nl, "zone: home"); Check(ZoneFor("DE") == Zone::Eu, "zone: eu"); Check(ZoneFor("CA") == Zone::World, "zone: world"); // ── order totals ────────────────────────────────────────────────── Check(ZoneShipping(1500, 2500, 5500, "NL") == 1500, "ship: NL zone"); Check(ZoneShipping(1500, 2500, 5500, "DE") == 2500, "ship: EU zone"); Check(ZoneShipping(1500, 2500, 5500, "CA") == 5500, "ship: world zone"); // NL: gross + shipping, VAT included in both. auto nl = ComputeTotals(58000, 1, 1500, "NL"); Check(nl.goods == 58000 && nl.shipping == 1500 && nl.total == 59500, "totals: NL"); Check(nl.vatIncluded, "totals: NL includes VAT"); Check(nl.vatCharged == 59500 - NetFromGross(59500), "totals: NL VAT covers shipping"); auto de = ComputeTotals(58000, 1, 2500, "DE"); Check(de.goods == 58000 && de.shipping == 2500 && de.total == 60500, "totals: EU"); // Export: net goods, world shipping, no VAT. auto ca = ComputeTotals(58000, 1, 5500, "CA"); Check(ca.goods == 47934 && ca.shipping == 5500 && ca.total == 53434, "totals: export"); Check(!ca.vatIncluded && ca.vatCharged == 0, "totals: export carries no VAT"); // Quantity: the export net is derived from the LINE total, not per unit — // per-unit rounding times qty would differ by a cent here, and the JS // preview mirrors this exact formula. auto ca2 = ComputeTotals(57500, 2, 5500, "CA"); Check(ca2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit"); Check(ca2.goods == 95041, "totals: 2× green export net exact"); auto nl2 = ComputeTotals(57500, 3, 1500, "NL"); Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross"); // ── the compiled-in catalogue ───────────────────────────────────── // Content is code now; these assertions are the contract the shop pages // rely on, checked against the actual shipped data. { const auto& products = Content::Products(); Check(products.size() == 1, "content: one product"); if (products.size() == 1) { const Product& pr = products[0]; Check(pr.slug == "fp6-pmos", "content: product slug"); // Coming-soon is the pre-launch state; launch flips it to // "available" and this check keeps passing either way. Check(pr.Buyable() || pr.ComingSoon(), "content: product is buyable or deliberately coming soon"); Check(pr.variants.size() == 3, "content: three colours"); // Cost-plus pricing, derived in code: supplier + €50, exactly. Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 56330, "content: green = 513.30 supplier + 50 markup"); Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 56930, "content: black = 519.30 supplier + 50 markup"); Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 65488, "content: white = 604.88 supplier + 50 markup"); Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null"); Check(pr.priceInclMinor == 56330, "content: from-price is the cheapest variant"); Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green", "content: cheapest is green"); Check(pr.safetyNote.find("112") != std::string::npos && pr.safetyNote.find("not yet verified") != std::string::npos, "content: emergency-calling safety warning present and honest"); Check(pr.warranty.find("TODO") == std::string::npos && pr.warranty.size() > 100, "content: warranty is written, not a placeholder"); // The product page's schema.org record must parse with our own // JSON parser and carry one offer per colour — the offers are // built from the same integers the checkout charges. { auto pp = Views::RenderProduct(pr, Rates{}); auto ld = Json::Parse(pp.meta.jsonLd); bool offersOk = false; if (ld && ld->IsObject()) { if (const Json::Value* o = ld->Find("offers"); o && o->IsArray()) { offersOk = o->array.size() == pr.variants.size(); } } Check(ld && ld->IsObject() && ld->Str("@type") == "Product" && offersOk, "schema: product JSON-LD parses, one offer per variant"); } } Check(!Content::Projects().empty(), "content: projects present"); Check(Content::LegalPages().size() == 3, "content: three legal pages"); Check(Content::AboutPage().sections.size() >= 3 && !Content::AboutPage().sections[0].body.empty() && Content::AboutPage().sections[0].body[0].find("Jorijn van der Graaf") != std::string::npos, "content: about page names the founder"); Check(!Content::Demos().empty(), "content: demos present"); } // ── the Sendcloud response parser ───────────────────────────────── { const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[ {"name":"Other Method","countries":[{"iso_2":"NL","price":1.00}]}, {"name":"DHL For You Home","countries":[ {"iso_2":"NL","price":6.25}, {"iso_2":"DE","price":8.20}, {"iso_2":"CA","price":42.50}, {"iso_2":"XX","price":0}, {"iso_2":"TOOLONG","price":5.00}]}]})", "DHL For You"); Check(table.method == "DHL For You Home", "sendcloud: method matched by substring"); Check(table.Find("NL") == 625, "sendcloud: NL price to cents"); Check(table.Find("DE") == 820, "sendcloud: 8.20 rounds exactly"); Check(table.Find("CA") == 4250, "sendcloud: CA price"); Check(table.Find("XX") == 0, "sendcloud: zero price dropped"); Check(table.Find("TOOLONG") == 0, "sendcloud: malformed iso dropped"); Check(Server::ParseSendcloudMethods("garbage", "x").perCountry.empty(), "sendcloud: malformed payload yields nothing"); // Comma-separated merge: courier for Europe, post for the world; the // earlier method keeps any country both cover. const auto merged = Server::ParseSendcloudMethods(R"({"shipping_methods":[ {"name":"DPD Home","countries":[ {"iso_2":"NL","price":7.13},{"iso_2":"DE","price":10.49}]}, {"name":"PostNL Parcels non-EU","countries":[ {"iso_2":"CA","price":23.95},{"iso_2":"US","price":17.94}, {"iso_2":"DE","price":99.99}]}]})", "DPD Home, PostNL Parcels non-EU"); Check(merged.Find("NL") == 713 && merged.Find("CA") == 2395, "sendcloud: merged table covers both methods"); Check(merged.Find("DE") == 1049, "sendcloud: earlier method wins a shared country"); Check(merged.method == "DPD Home + PostNL Parcels non-EU", "sendcloud: merged method names recorded"); } // ── indicative conversion ───────────────────────────────────────── // €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units. Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units"); Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate"); auto ca$ = CurrencyFor("CA"); Check(ca$.has_value() && ca$->code == "CAD", "fx: CA -> CAD"); Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion"); Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion"); if (ca$) { Check(FormatIndicative(*ca$, 920) == "≈ CA$920", "fx: display form"); } // ── order tokens and references ─────────────────────────────────── Check(IsOrderToken("0123456789abcdef0123456789abcdef"), "token: valid shape"); Check(!IsOrderToken("0123456789ABCDEF0123456789ABCDEF"), "token: uppercase rejected"); Check(!IsOrderToken("0123456789abcdef0123456789abcde"), "token: short rejected"); Check(!IsOrderToken("0123456789abcdef0123456789abcdeg"), "token: non-hex rejected"); const std::string tok = Server::NewOrderToken(); Check(IsOrderToken(tok), "token: generator emits valid tokens", tok); Check(Server::NewOrderToken() != tok, "token: not constant"); Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF", "reference: derived and uppercased"); // ── the wire-amount parser (bunq responses) ─────────────────────── using Server::ParseAmountToMinor; Check(ParseAmountToMinor("614.00") == 61400, "amount: normal"); Check(ParseAmountToMinor("614") == 61400, "amount: no fraction"); Check(ParseAmountToMinor("614.5") == 61450, "amount: one fraction digit"); Check(ParseAmountToMinor("0.01") == 1, "amount: one cent"); Check(!ParseAmountToMinor("614.005").has_value(), "amount: three decimals rejected"); Check(!ParseAmountToMinor("-1.00").has_value(), "amount: negative rejected"); Check(!ParseAmountToMinor("+1.00").has_value(), "amount: sign rejected"); Check(!ParseAmountToMinor("1e3").has_value(), "amount: exponent rejected"); Check(!ParseAmountToMinor("1.").has_value(), "amount: trailing dot rejected"); Check(!ParseAmountToMinor(".5").has_value(), "amount: bare fraction rejected"); Check(!ParseAmountToMinor("").has_value(), "amount: empty rejected"); Check(!ParseAmountToMinor("1 000.00").has_value(), "amount: separator rejected"); // ── the Mollie payment parser ───────────────────────────────────── { const auto p1 = Server::ParseMolliePayment(R"({ "resource":"payment","id":"tr_7UhSN1zuXS","status":"open","method":null, "amount":{"value":"578.30","currency":"EUR"}, "_links":{"checkout":{"href":"https://www.mollie.com/checkout/select-method/7UhSN1zuXS","type":"text/html"}}})"); Check(p1.has_value(), "mollie: open payment parses"); if (p1) { Check(p1->id == "tr_7UhSN1zuXS", "mollie: id"); Check(p1->status == "open", "mollie: status"); Check(p1->amountMinor == 57830, "mollie: amount to cents"); Check(p1->checkoutUrl == "https://www.mollie.com/checkout/select-method/7UhSN1zuXS", "mollie: checkout link"); Check(p1->method.empty(), "mollie: null method is empty"); } const auto p2 = Server::ParseMolliePayment(R"({ "id":"tr_x","status":"paid","method":"ideal", "amount":{"value":"578.30","currency":"EUR"},"_links":{}})"); Check(p2 && p2->status == "paid" && p2->method == "ideal", "mollie: paid payment carries the method"); const auto p3 = Server::ParseMolliePayment(R"({ "id":"tr_y","status":"paid","amount":{"value":"578.30","currency":"USD"}})"); Check(p3 && p3->amountMinor == 0, "mollie: non-EUR amount refuses to count"); Check(!Server::ParseMolliePayment("garbage").has_value(), "mollie: malformed payload rejected"); Check(!Server::ParseMolliePayment(R"({"status":"open"})").has_value(), "mollie: missing id rejected"); } // ── the invoice builder ─────────────────────────────────────────── { Server::OrderRecord o; o.token = "0123456789abcdef0123456789abcdef"; o.reference = "CC-TEST01"; o.invoiceNumber = "f57c6512-f012-4b91-adb3-077876480178-7"; o.invoicedAt = "2026-08-05T10:00:00Z"; o.createdAt = "2026-08-05T09:55:00Z"; o.paidVia = "ideal"; o.buyer = { "b@example.org", "Ada Lovelace", "Main St 1", "1234AB", "Delft", "NL" }; o.quantity = 2; o.unitMinor = 56330; o.goodsMinor = 112660; o.shippingMinor = 863; o.totalMinor = 113523; o.vatIncluded = true; const std::string eu = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green"); Check(eu.find("# Invoice f57c6512-f012-4b91-adb3-077876480178-7") != std::string::npos, "invoice: number heading"); Check(eu.find("* Customer number: f57c6512-f012-4b91-adb3-077876480178") != std::string::npos, "invoice: customer series shown separately"); Check(eu.find("* Invoice number: 7") != std::string::npos, "invoice: sequence within the series"); Check(eu.find("Chico Mendesring 256") != std::string::npos, "invoice: seller address"); Check(eu.find("3315NN Dordrecht") != std::string::npos, "invoice: seller city"); Check(eu.find("KVK 78437059") != std::string::npos, "invoice: KVK"); Check(eu.find("NL003329281B38") != std::string::npos, "invoice: VAT id"); Check(eu.find("CC-TEST01") != std::string::npos, "invoice: order reference"); Check(eu.find("Ada Lovelace") != std::string::npos, "invoice: buyer name"); Check(eu.find("Fairphone 6 — Forest Green") != std::string::npos, "invoice: item names the colour"); Check(eu.find("VAT 21% (NL)") != std::string::npos, "invoice: EU VAT line"); Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total"); Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export"); o.vatIncluded = false; o.buyer.country = "CA"; o.goodsMinor = 93107; o.shippingMinor = 2395; o.totalMinor = 95502; const std::string ex = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green"); Check(ex.find("VAT 0%") != std::string::npos, "invoice: export VAT 0%"); Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis"); Check(ex.find("€955.02") != std::string::npos, "invoice: export total"); } // ── rates loader ────────────────────────────────────────────────── const Rates r = LoadRates( R"({"date":"2026-08-04","micro_per_eur":{"USD":1083400,"CAD":1489000}})"); Check(r.date == "2026-08-04", "rates: date"); Check(r.Find("USD") == 1'083'400, "rates: lookup"); Check(r.Find("XXX") == 0, "rates: absent is zero"); Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none"); } std::string ReadFile(const std::filesystem::path& p) { std::ifstream in(p, std::ios::binary); if (!in) return {}; std::ostringstream buf; buf << in.rdbuf(); return buf.str(); } // Load content/ from disk. The wasm host reads the same bytes out of the VFS // instead; the loaders are shared, so only the source of the bytes differs. // Content loader for the CLI modes (--render, --routes, --sitemap, --feed). // // Must stay in step with Server::LoadContent, which the --serve path uses. They // are separate because the CLI wants a value it can pass around while the server // keeps process-wide state — but a field added to one and forgotten in the other // shows up as content silently missing from exactly one code path, which is how // products came to be absent from --routes and --sitemap while the live server // served them fine. Views::SiteContent LoadContent(const std::filesystem::path& root) { Views::SiteContent c; c.projects = Content::Projects(); c.products = Content::Products(); c.legal = Content::LegalPages(); c.demos = Content::Demos(); c.posts = LoadPosts(ReadFile(root / "posts.json")); c.rates = LoadRates(ReadFile(root / "rates.json")); return c; } } // namespace int main(int argc, char** argv) { const std::vector args(argv + 1, argv + argc); const auto has = [&](std::string_view f) { return std::find(args.begin(), args.end(), f) != args.end(); }; if (has("--selftest")) { RunSelfTest(); RunJsonSelfTest(); RunFormSelfTest(); RunMoneySelfTest(); if (failures == 0) { std::println("Catcrafts.Shared self-test: all assertions passed"); return 0; } std::println(std::cerr, "Catcrafts.Shared self-test: {} failure(s)", failures); return 1; } // --render : emit the full server-rendered document for a route. // // This is the SSR path in miniature, and it is how the markup gets // inspected without a browser: same renderers, same content files, same // output the server will eventually put on the wire. if (args.size() >= 2 && args[0] == "--render") { const Views::SiteContent content = LoadContent("content"); const Route route = ParseRoute(args[1]); const Views::RenderedPage page = Views::RenderRoute(route, content); std::print("{}", Views::RenderDocument( page, Views::RenderNav(route.kind == RouteKind::LegacyBlog ? RouteKind::Posts : route.kind), Views::RenderFooter(), /*bootScripts=*/"", // no wasm on a plain server render /*cssHref=*/"/styles.css")); return 0; } // --sitemap / --feed: generated from the same route table and Post model // the pages use, so they cannot drift from what the site actually serves. // The checked-in sitemap.xml this replaces still listed three blog posts // that no longer exist. // // Html::Escape's output is valid XML text: & < > " are // shared with XML, and it emits an apostrophe as the numeric reference // ' rather than the HTML-only '. So no separate XML escaper. if (has("--sitemap")) { const Views::SiteContent content = LoadContent("content"); std::print("\n" "\n"); for (std::string_view p : SitemapPaths()) { std::print(" https://catcrafts.net{}\n", Html::Escape(p).Str()); } // Product URLs come from the loaded catalogue rather than a second // hardcoded list, so the sitemap cannot advertise a product that does // not exist or miss one that does. for (const Product& pr : content.products) { std::print(" https://catcrafts.net/shop/{}\n", Html::Escape(pr.slug).Str()); } std::print("\n"); return 0; } if (has("--feed")) { const Views::SiteContent content = LoadContent("content"); std::print("{}", Views::RenderAtomFeed(content.posts)); return 0; } // --routes: status + title for every route, for a quick smoke check. if (has("--routes")) { const Views::SiteContent content = LoadContent("content"); for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/shop/nope", "/order/0123456789abcdef0123456789abcdef", "/order/not-a-token", "/legal/privacy", "/legal/imprint", "/legal/terms", "/legal/nope", "/projects", "/posts", "/demos", "/demos/raytracer", "/demos/nope", "/demo", "/projects/", "/blog", "/blog/hello-world", "/nope" }) { const Route r = ParseRoute(p); const Views::RenderedPage page = Views::RenderRoute(r, content); std::println("{:<22} status={} bytes={:<6} title={}", p, page.status, page.main.Size(), page.meta.title); } return 0; } // --serve [port] [--content=DIR] [--webroot=DIR] // // Plaintext HTTP/1.1 for Caddy to reverse-proxy to; see // Catcrafts.Server-Http.cpp for why not HTTP/3. // // Both directories are options rather than fixed paths because the // development layout and the deployed layout differ: in the repo the // content sits in ./content and the wasm bundle under ./bin/Catcrafts.Net-*/, // while on the server the content is installed next to the binary and the // bundle IS the webroot Caddy serves. if (!args.empty() && args[0] == "--serve") { std::uint16_t port = 8081; std::filesystem::path contentDir = "content"; std::filesystem::path webroot; // Default alongside the content in dev; the systemd unit points this at // /var/lib/catcrafts, which is deliberately NOT the web root — that // directory is publicly served and wiped by rsync --delete each deploy. std::filesystem::path ordersPath = "orders.jsonl"; // Payment rail selection. Flags beat environment beats default. The // default is "whichever provider has a key, off otherwise" so a box // with no credentials serves the whole site minus checkout instead of // refusing to start. Mollie outranks bunq: bunq.me's per-method limits // (€500/card, nothing for non-EU buyers) disqualified it as the // checkout; the client is kept for a possible future account sweep. const char* mollieKey = std::getenv("MOLLIE_API_KEY"); const char* bunqKey = std::getenv("BUNQ_API_KEY"); std::string railMode = mollieKey && *mollieKey ? "mollie" : bunqKey && *bunqKey ? "bunq" : "off"; bool bunqSandbox = [] { const char* v = std::getenv("BUNQ_SANDBOX"); return v && std::string_view(v) == "1"; }(); std::filesystem::path railState; std::string redirectBase = [] { const char* v = std::getenv("ORDER_REDIRECT_BASE"); return v && *v ? std::string(v) : std::string("https://catcrafts.net"); }(); for (std::size_t i = 1; i < args.size(); ++i) { const std::string_view a = args[i]; if (a.starts_with("--content=")) { contentDir = a.substr(10); } else if (a.starts_with("--webroot=")) { webroot = a.substr(10); } else if (a.starts_with("--orders=")) { ordersPath = a.substr(9); } else if (a.starts_with("--rail=")) { railMode = a.substr(7); } else if (a.starts_with("--bunq=")) { railMode = a.substr(7); // legacy alias for --rail= } else if (a.starts_with("--rail-state=")) { railState = a.substr(13); } else if (a.starts_with("--bunq-state=")) { railState = a.substr(13); // legacy alias for --rail-state= } else if (a.starts_with("--redirect-base=")) { redirectBase = a.substr(16); } else { std::uint32_t parsed = 0; if (std::from_chars(a.data(), a.data() + a.size(), parsed).ec == std::errc{} && parsed > 0 && parsed <= 65535) { port = static_cast(parsed); } else { std::println(std::cerr, "--serve: unrecognised argument '{}'", a); return 2; } } } // The bundle's index.html supplies the