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