Files
simplex-chat/apps/simplex-badge-service/web

SimpleX badge codes — web

The buyer-facing page for badge codes: choose a tier and duration, pay by card, BTC or XMR, and receive a code. Design and rationale are in plans/badges-codes/2026-08-27-badge-codes.md; the implementation plan for this app is plans/badges-codes/2026-08-28-web-implementation.md. This file covers how to build, test and run it.

No runtime or dev dependencies beyond typescript and @types/node. Stripe.js is loaded at runtime from js.stripe.com, never bundled or installed.

Build and test

npm install
npm run build
npm test

npm run build clears build/ and runs tsc into it (gitignored), then build.js, which content-hashes the compiled modules, styles.css and the images, copies them to dist/assets/<hash>/, and writes that hash into public/index.html and public/sw.js. dist/ (gitignored) is the deploy tree: index.html, sw.js and assets/<hash>/, nothing else.

npm test compiles and runs the suite (node --test over compiled output) but does not run build.js. One of those tests is a tripwire: it recomputes the hash from the freshly compiled modules and fails if the hash named in public/index.html or public/sw.js does not match.

If you edit anything under src/, run npm run build and commit the regenerated public/index.html and public/sw.js. Skipping this is the most likely way to break the suite — npm test will tell you, but the fix is npm run build, not the test.

Both scripts clear build/ first. tsc leaves the output of a module you rename or delete behind, build.js hashes every .js it finds there, and the result is a phantom module in the build that the tripwire would tell you to commit.

Layout

Only src/main.ts and src/stripe.ts touch window, location, history or localStorage. src/screens.ts builds the DOM, and src/qr.ts and src/icons.ts reach for document only through createElementNS, to build inline SVG. Nothing else outside those five modules touches the DOM. Every other module is plain TypeScript, tested directly in Node.

Module Owns
domain.ts The words the app is written in: steps, themes, order statuses, methods, chains
catalog.ts Prices and offers compiled into the page at build time
codes.ts The badge code alphabet and check character
parse.ts Readers for data the page did not produce, each answering the value or undefined
format.ts Money, countdowns and elapsed time as the words a screen prints
store.ts The three localStorage keys — session, orders and the chosen theme
order.ts What an order's state means: what to keep from a response, which screen it selects, and its history row
routing.ts Reading ?order=, the step hash with its ?app= flag, and the store into what to render; the simplexchat: link that hands a code to the app
api.ts The three routes — create, cancel and read — and the long-poll wait loop, with fetch, sleep and the clock injected
flow.ts Payment flow control logic — pure, no DOM, no globals
stripe.ts Loading Stripe.js, mounting the Payment Element, confirming, and refusing the card path with no key
icons.ts The badge art, the payment marks and the hamburger, built with createElementNS. The brand mark is NOT here: it is a served file
qr.ts A QR encoder written for this page, with no dependency, no network and no raster
screens.ts Every screen of the spec, and the header and its menu, built node by node — markup is never assigned from a string
main.ts Wiring: DOM events in, flow.ts calls out, screens.ts renders

Running the real service against this build

mock/server.py is not the service. The service is the Haskell executable simplex-badge-service, in apps/simplex-badge-service/, and dist/ is the directory its listener serves. Nothing about the page changes between the two — the service matches this build, never the reverse.

# 1. build the page. dist/ is the deploy tree: index.html, sw.js, assets/<hash>/
cd apps/simplex-badge-service/web
npm install && npm run build
cd ../../..

# 2. configuration, from the committed example. Copy it IN
#    PLACE, beside the example: that path is the one .gitignore covers, and this
#    file is about to hold a real api_key and webhook_secret.
cd apps/simplex-badge-service
cp badge_service.ini.example badge_service.ini
cd ../..
#   static_dir = ./apps/simplex-badge-service/web/dist   <- the build above
#   [btcpay]   = a real store's host, api_key, store_id and webhook_secret,
#                or delete the whole section to disable BTC and XMR (the provider-unavailable screen)
#   [issuer]   = uncomment the section and put a real issuer secret in private_key,
#                with its index in index. It has to be a key whose public half already
#                ships in the apps: startup checks the secret against `badgePublicKeys`
#                at that index, so a fresh `simplex-chat badge keygen` pair is refused.

# 3. run it. Without --service-config the web listener does not start at all.
#    An issuer key is required even though nothing in the checkout path uses it:
#    the same process also answers redemption requests, and it refuses to start
#    without a key it could sign a credential with. Put it in the ini's [issuer]
#    section, or pass --issuer-key-idx and --issuer-secret, which override it.
#    `simplex-chat badge keygen` prints a pair.
cabal run simplex-badge-service -- \
  --service-config apps/simplex-badge-service/badge_service.ini

Then open the [listener] host and port — http://127.0.0.1:8080/ as the example ships. static_dir is relative to the working directory, so run from the repository root or make it absolute.

The executable is the whole badge service, not just this listener. badgeService starts simplexChatCore unconditionally and the web listener is one lane beside the chat one, so a local run opens or creates a chat database (printDbOpts names the path at startup) and does agent network work. --no-address skips creating the service's contact address on first start, which a run of the web listener alone does not need.

These three steps are read out of Options.hs, Service.hs, Config.hs and badge_service.ini.example rather than from a run — driving them end to end needs a chat database and a real BTCPay store.

What the mock cannot stand in for:

mock/server.py simplex-badge-service
Invoices invented in memory created at BTCPay over Greenfield
Payment detected by POST /control/settle/:id, an endpoint that exists nowhere else a poller reading the provider, which is the only thing that carries authority
Webhooks none POST /webhooks/btcpay, signature-verified, a latency hint and nothing more
Persistence none the chat database, under sx_badge_service_*
Codes invented written unpaid at checkout, marked paid by settlement

The Haskell side's own end-to-end coverage of that lane — checkout, payment, polling, partial payment, expiry, late settlement and replay, all against a fake Greenfield — is tests/Bots/BadgeService/WebTests.hs:

cabal test --test-options='-m "/SimpleX badge service/"'

Running the mock

mock/server.py stands in for the Haskell service, Stripe and BTCPay, so the whole browser flow can be driven with no real backend. It is a browser-only test fixture and not the service: no signatures, no persistence, no real money, no provider, and not a specification of what ships. Standard library only.

python3 mock/server.py --port 8099

It serves public/ and dist/, and adds:

Endpoint Behaviour
POST /api/invoice Creates an invoice. Rejects a repeated codeHash with 409 code_conflict.
GET /api/invoice/:id?wait=<status>&seenPaid=<figure>&seenFull=<0|1> Long-polls: holds while the invoice's status is still <status> and its payment is the one the page says it has rendered, up to MOCK_HOLD_SECONDS (default 30). A payment the page has not seen answers at once — the provider's verdict counts as much as the figure, since Monero reports an invoice as confirming while its figures are still zero. A request that omits seenPaid holds on the status alone.
POST /api/invoice/:id/cancel Expires an open invoice. Refuses a settled one with 409 not_open and a funded one with 409 funded, as the service does.
POST /control/settle/:id Marks the invoice paid — stands in for a provider webhook.
POST /control/expire/:id Marks it expired.
POST /control/partial/:id Records a partial payment, half the amount due, and the remainder the provider would still ask for.
POST /control/confirming/:id Records the full amount as arrived with the invoice still open — the screen that waits for confirmations.
POST /control/verdict/:id Records the provider's verdict alone, with no figure — how Monero reports a payment it is still confirming.

Driving a purchase by hand

python3 mock/server.py --port 8099 &

curl -s -X POST http://localhost:8099/api/invoice -H 'content-type: application/json' \
  -d '{"codeHash":"<43 base64url chars>","priceId":"price_supporter","offerId":"offer_3m","method":"btc"}'
# => {"invoiceId": "...", "status": "open", ...}

curl -s -X POST http://localhost:8099/control/settle/<invoiceId>
# => {"ok": true, "status": "paid"}

curl -s "http://localhost:8099/api/invoice/<invoiceId>?wait=open"
# returns immediately once settled, with status "paid"

A GET with wait=open held while open is still current unblocks the instant /control/settle (or /control/expire, /control/partial) fires, which is what the page's own wait loop relies on — no polling on a timer.

Card invoices ("method":"card") get a clientSecret in the response instead of an address; BTC and XMR get address and cryptoAmount.

Opening the page as the app does

The SimpleX app opens the page at a step hash carrying a flag, which decides only how the code is handed over at the end. Both endings can be driven here:

http://localhost:8099/#/tier?app=true      # Return to SimpleX, with Show code under it
http://localhost:8099/#/tier?app=desktop   # the code screen, saying the Redeem code screen is already open

With no flag the page ends as it always has.

Open each in a fresh private window. The flag is read once, at load, and kept in the session; nothing re-reads it when the hash changes, so editing the address bar of a page that is already open does nothing. A session left over from an earlier purchase in the same browser has already spent its flag at checkout, and the ending falls back to the plain code screen.

Pay with Monero or Bitcoin and settle it with /control/settle above — the card path needs a Stripe key. A code the browser could not store also falls back to the plain screen, so a browser that blocks localStorage never shows the app=true ending at all.

The Stripe key

The publishable key lives in a <meta id="stripe-publishable-key"> element in public/index.html, committed empty. mock/server.py substitutes $STRIPE_PUBLISHABLE_KEY into the served page and refuses to start if it is set to anything but a pk_-prefixed key (a secret or restricted key would otherwise be baked into a page anyone can read).

With no key set there is no card path at all: cardPlan answers unconfigured, renderCardForm returns before it builds anything, and choosing Card reaches "The card form did not load". Drive a purchase with Monero or Bitcoin instead, or set a test key:

STRIPE_PUBLISHABLE_KEY=pk_test_... python3 mock/server.py --port 8099

What is not verified here

The test suite runs in Node, so it asserts structure rather than rendering: CSS is checked by parsing styles.css, inert by the attribute, the QR by decoding the path the encoder produced, and the service worker by driving public/sw.js in a Node vm.

The screens themselves have been rendered and reviewed, in both themes at desktop and phone widths, using headless Chromium driven by Playwright installed outside this package — the whole purchase was walked through, settled against the mock, and photographed. Nothing in package.json changed; do the same rather than trusting the suite for anything visual.

What still cannot be checked anywhere here:

  • A real QR scan with a phone camera.
  • Stripe.js loading, mounting a Payment Element and confirming a payment, which need a browser and a Stripe account. The SDK method names come from the spec and every test drives a fake, so a wrong name stays green here and fails in production.
  • A real service worker installing and serving the precache with the network genuinely off.
  • System fonts: headless Chromium substitutes DejaVu, so type metrics differ from a real machine.

Before shipping a change that touches any of these, check it by hand in a real browser: the wizard's two panels travel together for the length of a step and neither one blinks out, panels ahead of the buyer are unreachable by Tab, the menu closes on Escape and hands focus back to its button, both themes are drawn from the menu's own control as well as from the operating system, offline reload actually serves the cached build, and (with a test key) the Payment Element mounts and confirms.

What is not implemented

The web manifest is absent. The design calls it optional: offline support needs the service worker, not the manifest.

The asset table is otherwise built. The brand mark is the difference worth knowing about: public/img/wordmark-*.svg and public/img/symbol-*.svg are the official files copied out of website/ and media-logos/, served under the build hash and drawn by the stylesheet — NOT path data transcribed into a module. design.test.ts compares each one against its source byte for byte, so a hand-edited mark fails the suite. The one edit is the dark wordmark, which is the light file with its #030749 lettering set to white, exactly as simplex.chat's own dark header shows it.

Content Security Policy

This repository has no CSP — it belongs on the listener that serves the app in production, not in this app. Without one, in particular without script-src https://js.stripe.com, card payments break silently and nothing in this test suite would catch it. The policy the spec specifies:

default-src     'self';
script-src      'self' https://js.stripe.com https://*.js.stripe.com;
frame-src       https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com;
connect-src     'self' https://api.stripe.com;
img-src         'self' https://*.stripe.com;
frame-ancestors 'self' https://simplex.chat https://*.simplex.chat

frame-ancestors is what lets the site embed this app in an iframe while every other origin is refused (clickjacking). Drop the simplex.chat entries to forbid embedding entirely.

Embedding in the site

The app runs standalone or inside an iframe on simplex.chat. It detects the frame (window.self !== window.top), points the wordmark at the site's own home on the top window, and speaks a small theme protocol so one switch drives both (src/embed.ts):

  • On load the frame posts { type: "simplex-embed-ready", theme } to its parent.
  • The host posts { type: "simplex-theme", theme } (theme is light | dark | system) to drive the frame; the frame applies it only from a trusted origin (https://simplex.chat or a subdomain).
  • When the buyer uses the in-frame theme control, the frame echoes the same message back so the host's own control stays in step.

The host page hides its own navbar and frames the app full-bleed:

<iframe id="badges" src="https://badges.simplex.chat/"
        style="width:100%;height:100vh;border:0;display:block"></iframe>
<script>
  const frame = document.getElementById("badges");
  const APP = "https://badges.simplex.chat";
  // current site theme: "light" | "dark" | "system"
  const send = (theme) => frame.contentWindow.postMessage({ type: "simplex-theme", theme }, APP);
  window.addEventListener("message", (e) => {
    if (e.origin !== APP) return;
    if (e.data?.type === "simplex-embed-ready") send(currentSiteTheme());
    if (e.data?.type === "simplex-theme") applySiteTheme(e.data.theme); // the in-frame toggle echoed back
  });
  // call send(theme) whenever the site's own theme control changes.
</script>