diff --git a/apps/simplex-badge-service/web/.gitignore b/apps/simplex-badge-service/web/.gitignore
new file mode 100644
index 0000000000..07aec67cf3
--- /dev/null
+++ b/apps/simplex-badge-service/web/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+# dist/ is committed: the service embeds it, so the Haskell build never needs
+# node. This overrides the repository-wide `dist` rule.
+!dist/
diff --git a/apps/simplex-badge-service/web/assets/.gitkeep b/apps/simplex-badge-service/web/assets/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/apps/simplex-badge-service/web/build.mjs b/apps/simplex-badge-service/web/build.mjs
new file mode 100644
index 0000000000..4a358ffaae
--- /dev/null
+++ b/apps/simplex-badge-service/web/build.mjs
@@ -0,0 +1,115 @@
+// Build for the supporter-badge checkout site. Run it with `npm run build`.
+//
+// Compiles src/ to ES modules in dist/, copies assets/ in beside them, checks
+// that the emitted modules can resolve each other in a browser, and writes
+// dist/dev.html — index.html with its @@name@@ tokens resolved, for design work
+// over a local static server before the service can serve the real page.
+//
+// Node's own modules only: this project is capped at one devDependency, tsc.
+
+import {execFileSync} from "node:child_process"
+import {cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync} from "node:fs"
+import {dirname, join, posix, relative, resolve, sep} from "node:path"
+import {fileURLToPath} from "node:url"
+
+const webDir = dirname(fileURLToPath(import.meta.url))
+const assetsDir = join(webDir, "assets")
+const distDir = join(webDir, "dist")
+const tscBin = join(webDir, "node_modules", "typescript", "bin", "tsc")
+
+// Served from web/ rather than from dist/, so that an edit to styles.css is
+// picked up without a rebuild in web_dir mode. dev.html sits in dist/, so it
+// reaches both of them one directory up.
+const ROOT_FILES = ["index.html", "styles.css"]
+// Generated into dist/, never served: the service filters it out of the assets.
+const DEV_HTML = "dev.html"
+// The only token that names something other than a file. The service reads the
+// real value from [web] support_contact; dev.html has no configuration to read.
+const NON_FILE_TOKENS = new Map([["support_contact", "https://example.invalid/dev-support-contact"]])
+const TOKEN_RE = /@@([\w.-]+)@@/g
+// Relative import specifiers in the emitted modules: `from "./x.js"`,
+// `import "./x.js"` and `import("./x.js")`. Textual, so a specifier-shaped
+// string inside a comment or a literal would be checked too; that only ever
+// reports a path that does not exist, which is worth knowing either way.
+const IMPORT_RES = [/\bfrom\s*["'](\.[^"']*)["']/g, /^\s*import\s*["'](\.[^"']*)["']/gm, /\bimport\s*\(\s*["'](\.[^"']*)["']\s*\)/g]
+
+// Rebuild from empty, so that a deleted module or asset also leaves dist/ and
+// the committed build stays a function of the sources alone.
+rmSync(distDir, {recursive: true, force: true})
+
+compile()
+copyAssets()
+const built = listFiles(distDir)
+checkImportsResolve(built)
+writeDevHtml(built)
+console.log(`built ${built.length} asset(s) and ${DEV_HTML} into ${relative(webDir, distDir)}/`)
+
+function compile() {
+ // Spawned through node rather than through the PATH shim, so that the script
+ // also works when run as `node build.mjs`.
+ execFileSync(process.execPath, [tscBin, "--project", webDir], {stdio: "inherit"})
+}
+
+function copyAssets() {
+ mkdirSync(distDir, {recursive: true})
+ for (const entry of readdirSync(assetsDir)) {
+ // .gitkeep and friends: they keep the directory in git, they are not assets.
+ if (entry.startsWith(".")) continue
+ cpSync(join(assetsDir, entry), join(distDir, entry), {recursive: true})
+ }
+}
+
+// Every file in dir, as a path relative to it, in POSIX form and sorted.
+function listFiles(dir) {
+ const walk = (d) =>
+ readdirSync(d, {withFileTypes: true}).flatMap((e) => {
+ const p = join(d, e.name)
+ return e.isDirectory() ? walk(p) : [relative(dir, p).split(sep).join(posix.sep)]
+ })
+ return walk(dir).sort()
+}
+
+// tsc does not rewrite import specifiers, so an extensionless or misspelled one
+// compiles cleanly and then 404s in the browser, leaving a blank page.
+function checkImportsResolve(built) {
+ const emitted = new Set(built)
+ for (const name of built) {
+ if (!name.endsWith(".js")) continue
+ const source = readFileSync(join(distDir, name), "utf8")
+ for (const re of IMPORT_RES) {
+ for (const [, specifier] of source.matchAll(re)) {
+ const target = relative(distDir, resolve(dirname(join(distDir, name)), specifier)).split(sep).join(posix.sep)
+ if (!emitted.has(target)) {
+ throw new Error(`${name} imports "${specifier}", which is not in the build. A browser would fail to load it.`)
+ }
+ }
+ }
+ }
+}
+
+// index.html with every token resolved. The rule is the service's, minus the
+// hashed prefix: a token naming a file in the served set resolves to that file,
+// anything else is an error. Nothing here knows which files exist today, so an
+// asset added later needs a token and no change to this script.
+function writeDevHtml(built) {
+ // dist/ was wiped above and dev.html is written last, so it is not in `built`
+ // and cannot resolve a token of its own name.
+ const served = new Map()
+ for (const name of built) served.set(name, `./${name}`)
+ for (const name of ROOT_FILES) served.set(name, `../${name}`)
+ const html = readFileSync(join(webDir, "index.html"), "utf8").replace(TOKEN_RE, (_, token) => {
+ const href = served.get(token)
+ if (href !== undefined) return href
+ const value = NON_FILE_TOKENS.get(token)
+ if (value !== undefined) return value
+ throw new Error(
+ `index.html references @@${token}@@, which is neither a file in the served set ` +
+ `(${[...served.keys()].join(", ")}) nor a known non-file token. ` +
+ `Add the file to assets/ or src/, or add the token to NON_FILE_TOKENS here and to the service.`
+ )
+ })
+ // One short line: everything above index.html's charset declaration has to
+ // fit in the 1024 bytes HTML5 gives it.
+ const banner = "\n"
+ writeFileSync(join(distDir, DEV_HTML), banner + html)
+}
diff --git a/apps/simplex-badge-service/web/dist/dev.html b/apps/simplex-badge-service/web/dist/dev.html
new file mode 100644
index 0000000000..9fa6fbb4eb
--- /dev/null
+++ b/apps/simplex-badge-service/web/dist/dev.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+ SimpleX supporter badges
+
+
+
+
+
+
+
+
diff --git a/apps/simplex-badge-service/web/dist/main.js b/apps/simplex-badge-service/web/dist/main.js
new file mode 100644
index 0000000000..07da9f6dc5
--- /dev/null
+++ b/apps/simplex-badge-service/web/dist/main.js
@@ -0,0 +1,7 @@
+// Placeholder entry point; the wizard shell replaces it.
+// The relative specifier keeps its ".js" extension into the emitted module:
+// tsc does not rewrite specifiers and the browser resolves them itself.
+import { renderPlaceholder } from "./ui.js";
+const app = document.getElementById("app");
+if (app)
+ renderPlaceholder(app);
diff --git a/apps/simplex-badge-service/web/dist/ui.js b/apps/simplex-badge-service/web/dist/ui.js
new file mode 100644
index 0000000000..3c9a896835
--- /dev/null
+++ b/apps/simplex-badge-service/web/dist/ui.js
@@ -0,0 +1,6 @@
+// Placeholder view layer; the wizard shell replaces it.
+export function renderPlaceholder(root) {
+ const heading = document.createElement("h1");
+ heading.textContent = "SimpleX supporter badges";
+ root.replaceChildren(heading);
+}
diff --git a/apps/simplex-badge-service/web/index.html b/apps/simplex-badge-service/web/index.html
new file mode 100644
index 0000000000..34d230cad8
--- /dev/null
+++ b/apps/simplex-badge-service/web/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ SimpleX supporter badges
+
+
+
+
+
+
+
+
diff --git a/apps/simplex-badge-service/web/package-lock.json b/apps/simplex-badge-service/web/package-lock.json
new file mode 100644
index 0000000000..e73b16d2ac
--- /dev/null
+++ b/apps/simplex-badge-service/web/package-lock.json
@@ -0,0 +1,30 @@
+{
+ "name": "simplex-badge-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "simplex-badge-web",
+ "version": "0.1.0",
+ "license": "AGPL-3.0",
+ "devDependencies": {
+ "typescript": "^5.9.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ }
+ }
+}
diff --git a/apps/simplex-badge-service/web/package.json b/apps/simplex-badge-service/web/package.json
new file mode 100644
index 0000000000..a41e4ecbad
--- /dev/null
+++ b/apps/simplex-badge-service/web/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "simplex-badge-web",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Checkout site for SimpleX supporter badges, served by simplex-badge-service",
+ "scripts": {
+ "build": "node build.mjs"
+ },
+ "devDependencies": {
+ "typescript": "^5.9.3"
+ },
+ "author": "SimpleX Chat",
+ "license": "AGPL-3.0"
+}
diff --git a/apps/simplex-badge-service/web/src/main.ts b/apps/simplex-badge-service/web/src/main.ts
new file mode 100644
index 0000000000..d1fad003ab
--- /dev/null
+++ b/apps/simplex-badge-service/web/src/main.ts
@@ -0,0 +1,8 @@
+// Placeholder entry point; the wizard shell replaces it.
+// The relative specifier keeps its ".js" extension into the emitted module:
+// tsc does not rewrite specifiers and the browser resolves them itself.
+
+import {renderPlaceholder} from "./ui.js"
+
+const app = document.getElementById("app")
+if (app) renderPlaceholder(app)
diff --git a/apps/simplex-badge-service/web/src/ui.ts b/apps/simplex-badge-service/web/src/ui.ts
new file mode 100644
index 0000000000..61cd503dec
--- /dev/null
+++ b/apps/simplex-badge-service/web/src/ui.ts
@@ -0,0 +1,7 @@
+// Placeholder view layer; the wizard shell replaces it.
+
+export function renderPlaceholder(root: HTMLElement): void {
+ const heading = document.createElement("h1")
+ heading.textContent = "SimpleX supporter badges"
+ root.replaceChildren(heading)
+}
diff --git a/apps/simplex-badge-service/web/styles.css b/apps/simplex-badge-service/web/styles.css
new file mode 100644
index 0000000000..6319261e80
--- /dev/null
+++ b/apps/simplex-badge-service/web/styles.css
@@ -0,0 +1,12 @@
+/* Placeholder stylesheet; the design system replaces it.
+ Served from web/ rather than from dist/, so that an edit is picked up
+ without a rebuild when the service runs with [web] web_dir set. */
+
+:root {
+ color-scheme: light dark;
+}
+
+body {
+ margin: 0;
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+}
diff --git a/apps/simplex-badge-service/web/tsconfig.json b/apps/simplex-badge-service/web/tsconfig.json
new file mode 100644
index 0000000000..58708a738a
--- /dev/null
+++ b/apps/simplex-badge-service/web/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "include": ["src"],
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ES2020",
+ "moduleResolution": "Node",
+ "lib": ["ES2020", "DOM"],
+ "types": [],
+ "rootDir": "src",
+ "outDir": "dist",
+ "strict": true,
+ "strictNullChecks": true,
+ "noImplicitAny": true,
+ "noImplicitThis": true,
+ "noImplicitReturns": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmitOnError": true
+ }
+}
diff --git a/plans/badges-codes/2026-08-21-badges-web-checkout.md b/plans/badges-codes/2026-08-21-badges-web-checkout.md
index 571842d9de..c7e8a861fb 100644
--- a/plans/badges-codes/2026-08-21-badges-web-checkout.md
+++ b/plans/badges-codes/2026-08-21-badges-web-checkout.md
@@ -150,7 +150,7 @@ The two `-m` filters are needed because the badge tests live under two hspec pat
| C4 | Redeem path wired end to end | B6, B7, B9, C3 | ☑ |
| C5 | Client and service integration tests | B10, C4 | ☑ |
| D0 | Store layer: orders, invoices, provider events | A3, A5, B1 | ☑ |
-| D1 | Web project skeleton and tsc build | — | ☐ |
+| D1 | Web project skeleton and tsc build | — | ☑ |
| D2 | Design system and site wizard shell | D1 | ☐ |
| D3 | Catalog fetch and the four site screens | D2, D4 | ☐ |
| D4 | Warp listener, asset embedding, routing | A2, A4, A6, B1, D1 | ☐ |
@@ -765,7 +765,7 @@ Phase D ends with a browsable, priced site wizard whose Pay button reaches a rea
#### D1 — Web project skeleton and tsc build
-**Files:** `apps/simplex-badge-service/web/{package.json,package-lock.json,tsconfig.json,index.html,styles.css}`, `apps/simplex-badge-service/web/src/`, `apps/simplex-badge-service/web/assets/.gitkeep`, `apps/simplex-badge-service/web/dist/`
+**Files:** `apps/simplex-badge-service/web/{package.json,package-lock.json,tsconfig.json,build.mjs,index.html,styles.css,.gitignore}`, `apps/simplex-badge-service/web/src/`, `apps/simplex-badge-service/web/assets/.gitkeep`, `apps/simplex-badge-service/web/dist/`
**Do:**
@@ -778,7 +778,7 @@ Phase D ends with a browsable, priced site wizard whose Pay button reaches a rea
- Commit `dist/`, and say so in a header comment in `index.html`.
- Do not add a bundler, a framework, or a CSS toolchain.
-**Verify:** Manual: `npm ci && npm run build` from a clean checkout produces the `dist/` modules and `dist/dev.html`; `npx tsc --noEmit` is clean.
+**Verify:** `npm ci && npm run build` from a clean checkout produces the `dist/` modules, the copied assets and `dist/dev.html`; `npx tsc --noEmit` is clean; a second `npm run build` leaves `git diff --exit-code apps/simplex-badge-service/web/dist` empty, which is what D8 gates; `dist/dev.html` carries no unresolved `@@…@@` token, resolves `styles.css` to `../styles.css` and every other file token to `./`; the emitted `main.js` imports a sibling module by a relative specifier. Then serve `apps/simplex-badge-service/web` with `python3 -m http.server` and `curl` every URL the page references — `dev.html`, the stylesheet one level up, and both modules — for a 200. Rendering the page in a browser is D2's manual pass (§9).
#### D2 — Design system and site wizard shell
@@ -1475,6 +1475,13 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
- **D0 — `createOrder` takes an `invoiceId`, which the plan's field list did not name.** `@invoices.invoice_id` is `TEXT NOT NULL PRIMARY KEY` with no default and the store mints no identifiers (it opens no transaction either), so D6 mints it alongside the `orderId`. It must NOT be the `orderId` itself: the order id is a bearer capability for the code (decision 9) and putting it in a second table widens the surface for no gain. Two more shapes the field list left open, both now fixed in code: `@invoices.price` and `@invoices.amount` both carry A4's `offerTotal` with `discount_amount`/`credit_amount` NULL, because an offer's discount is expressed as free months so the total IS the price; and `@invoices.payment_crypto_currency` is not written at all (A3 — `method` is the single source).
- **D0 — every order read is an INNER join to `@invoices`, and three new `ServiceError` constructors.** `getOrder`, `getOrderByProviderRef`, `getOrderByShortRef` and `getStuckOrders` all go through one `orderSelect`, joined rather than left-joined: `createOrder` is the only writer of a `@web_orders` row and always writes the invoice with it, so an order without one does not exist and `WebOrder` can hold the invoice's NOT NULL columns unwrapped. `SEOrderNotFound` is what `updateOrderStatus`, `setOrderProviderRef` and `setOrderSettled` throw when the order they name is absent; `SEProviderEventNotFound` is `markProviderEventProcessed`'s, reachable only if it and `recordProviderEvent` disagree about the key. `SEInvoiceNotFound` is the invariant guard: the `@invoices.status` write is an `UPDATE … RETURNING` like every other writer here, so an order that resolves to no invoice fails instead of moving one status and leaving the other behind — `createOrder` makes that unreachable, and the guard is there for whoever next writes this table. **`setOrderSettled` takes `settledAt` and `now` separately**: the provider's reported settlement instant is routinely in the past for an on-chain confirmation, and writing it to `updated_at` would move that column backwards past an earlier `updateOrderStatus`; E3, F2 and H3 pass both. **`setOrderSettled` does not guard on the current status**: settlement is idempotent and monotonic toward `paid` (E3), but that is E3's rule to apply, because the same decision governs whether a code row is written — a guard here would answer `SEOrderNotFound` for a replay, which is worse than no guard.
- **D0 — `getStuckOrders`' status filter leaves a recovery gap, implemented as specified. H3 decides.** The step says `invoiced` or `pending`, and that is what shipped. But E3 can move `expired` and `failed` to `paid` on a late webhook, so an order that one webhook marked `expired` and that then settles on chain with THAT webhook missed is never re-read from the provider by H3's pass — the buyer has paid and only support (H2) recovers it. Missed webhooks are exactly what H3 exists for, so this is worth a decision rather than an assumption; widening the filter to all four non-`paid` statuses is a change to H3's contract, not to this query, and the haddock on `getStuckOrders` says so.
+- **D1 — the repository ignores `dist`, so the committed build needs `web/.gitignore` to re-include it.** `.gitignore:14` carries a bare `dist` under "# Haskell", which matches a directory of that name at any depth: `git add apps/simplex-badge-service/web/dist` was a silent no-op, and decision 7's committed artefact, D4's `embedDir` source and D8's gate would all have been comparing against a directory git never tracked. `web/.gitignore` now holds `node_modules/` and `!dist/`, following the per-package `.gitignore` convention of `packages/simplex-chat-webrtc` and `apps/simplex-support-bot` rather than editing the root file; a nested `.gitignore` overrides a parent's rule, and the negation works because no parent directory of `dist/` is itself excluded. Added to the step's **Files**. Anyone moving this directory must move the negation with it — `git status` will not report the loss.
+- **D1 — the build is `build.mjs`, which the step's Files list omitted, and it checks two things `tsc` cannot.** The step describes the build (compile, copy `assets/`, emit `dev.html`) without naming a file for it; decision 7's one-devDependency cap makes it a node script over `fs`/`path` alone. Two build-time checks were added, both of which fail loudly rather than producing a page that is broken only in a browser, which is the one environment CI does not have:
+ - **Relative import specifiers must resolve inside the build.** `tsc` does not rewrite specifiers (decision 7), so `import … from "./ui"` type-checks, emits, and then 404s in the browser leaving a blank page. Each emitted `.js` is scanned for relative specifiers and each must name an emitted file. This is the premise D4's single-prefix asset hash exists to protect, checked at the point it is created.
+ - **Every `@@…@@` token must name a file in the served set or a known non-file token,** or the build fails naming the token and listing the set. This is D4's startup rule ("a token naming a file that is not in the served set fails at startup"), applied one stage earlier; D4 should implement the same rule rather than a second, looser one.
+
+ `dist/` is also wiped before each build, so a deleted module or asset leaves the committed artefact rather than lingering in it and staying embedded; and `noEmitOnError` is on, so a type error cannot leave a half-written `dist/` for D8 to diff. Sources are compiled with `lib: ["ES2020", "DOM"]` and `types: []`: this is browser code and no `@types/node` is available under the cap.
+- **D1 — `index.html` may not use the token syntax in prose, and its charset declaration has a byte budget. Both bind D2 onwards.** The step requires a header comment saying `dist/` is committed, and the first draft of that comment used `@@name@@` to describe the mechanism — which the generic substitution then tried to resolve, correctly failing the build. Under D4 the same stray token fails the *service* at startup. Separately, the comment sits above ``, and HTML5 requires the encoding declaration to be complete within the first 1024 bytes; `dev.html` prepends a generated banner on top of it, so the budget is shared. The comment is short and ASCII-only for that reason, the banner is one line, and `index.html` says so. A step that grows the header comment has to re-check both.
## 10. End-to-end verification