* 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>
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 islight|dark|system) to drive the frame; the frame applies it only from a trusted origin (https://simplex.chator 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>