Files
simplex-chat/apps/simplex-badge-service/web/README.md
T
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

15 KiB

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= and the store into what to render
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 the no-key stand-in
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.

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, the card path renders a labelled development stand-in instead of a Stripe Payment Element: its button does what a successful confirm does, and settling it calls the mock's /control/settle directly. This stand-in cannot appear when a key is set — the code path that builds it is unreachable once Stripe.js has actually loaded. To see the real Payment Element, 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>