@barter.game/web-client
v0.0.1
Published
Browser SPA (installable PWA) served by a barter.game bank at /:bank/ui. Plain static assets, no build step — a bank host serves them through the AssetReader seam of @barter.game/bank-core.
Maintainers
Readme
barter.game web client
The reference web client: a build-less, framework-less vanilla-JS SPA that the bank serves itself. There is no bundler, no transpiler, and no build step — the files in this directory are exactly what the browser runs.
The contract this client implements is the protocol (base, bank-schema, bank-rpc). This app is one possible client — anyone can build their own against the same protocol. How a client manages keypairs (browser keystore, hardware token, paper) is deliberately outside the protocol; the scheme below is this client's choice.
How it is served
The bank (packages/bank-core —
router.ts,
ui.ts) hosts the SPA directly:
| Route | What it does |
|---|---|
| GET /:bank/ui | 308 redirect to /:bank/ui/. The trailing slash is load-bearing: scope matching for the service worker and the manifest is a plain string prefix, so the slashless URL sits outside its own app's scope and would never be installable |
| GET /:bank/ui/ | Returns index.html with <base href="/:bank/ui/"> injected into <head>, so the relative app/… asset refs resolve |
| GET /:bank/ui/app/* | Serves the static files from apps/web/ (the host resolves the directory itself — the AWS local server defaults to apps/web, the Lambda bundles it) |
| GET /:bank/ui/manifest.webmanifest | The install manifest, generated per bank (webManifest in ../../packages/bank-core/src/ui.ts) — id/start_url/scope all carry this bank's path prefix |
| GET /:bank/ui/sw.js | The service worker, served from the UI root so its scope covers the whole SPA |
| GET /:bank/ui/feed | The bank's own posts (its curated auto-reposts) as JSON — unauthenticated, with the mentioned vouchers' docs and released meta bundled. This is what the logged-out landing renders (bank-rpc.md §2.5) |
The SPA derives the bank name from the first URL path segment and boots by
fetching the public GET /:bank/ui/config for the bank's pubkey and URL.
Logged out, the landing page shows the bank's public feed (/ui/feed) under
the hero — every card is verified client-side before it renders.
Runtime dependencies are pinned in an import map in index.html and loaded
from esm.sh (@noble/ed25519 3.1.0, @noble/hashes 2.2.0, @scure/base
2.2.0, ulid 2.3.0); fonts come from Google Fonts. Nothing is bundled, and the
app is not offline-capable.
Consuming as an npm package
These assets are published as @barter.game/web-client so a bank host can
serve the SPA without checking out this repo. The package has no code entry
point — resolve its directory and feed it to the bank engine's AssetReader
seam (packages/bank-core src/types.ts):
npm install @barter.game/web-clientimport { createRequire } from 'node:module';
import { dirname, join, normalize, isAbsolute, sep } from 'node:path';
import { readFile } from 'node:fs/promises';
import type { AssetReader } from '@barter.game/bank-core';
const webDir = dirname(createRequire(import.meta.url)
.resolve('@barter.game/web-client/package.json'));
const assets: AssetReader = {
async read(path: string): Promise<Uint8Array | null> {
const normalized = normalize(path);
if (isAbsolute(normalized) || normalized.split(sep).includes('..')) return null;
try {
return new Uint8Array(await readFile(join(webDir, normalized)));
} catch {
return null;
}
},
};That is exactly the reference host's adapter
(apps/bank-aws/src/assets-fs.ts) with the
package directory as its root. The router serves index.html, app.js,
protocol.js, qr.js, styles.css, sw.js, the icons, and vendor/ from
it at /:bank/ui/app/*; the per-bank manifest is generated by the engine, not
shipped as a file.
Installing it (home screen)
The SPA is installable as a PWA — one bank, one installed app. Each bank gets
its own manifest whose scope is /:bank/ui/, so an installed app is confined
to the bank it was installed from and a Barter Link to a different bank opens
in the browser, where it belongs.
The offer is made in-app rather than left to the browser menu:
- Chromium fires
beforeinstallprompt;app.jscaptures it (suppressing Chrome's own mini-infobar) and shows a card on the welcome hero and the dashboard, plus an entry in#/settings. The button replays the captured event, which is single-use. - WebKit never fires it, so on iOS/iPadOS the same card shows the two Share-sheet steps instead.
- Where neither applies (Firefox, desktop Safari) the banner stays hidden — Settings still explains where to look.
- "Not now" snoozes the banner for 30 days (
barter.install_snoozedinlocalStorage); Settings is always available.
sw.js caches nothing, by design. Chromium only offers an install for a
page controlled by a service worker with a fetch handler that yields a response
while offline, so the worker handles exactly one case — top-level navigations,
answered from the network or, if that fails, with an inline "you're offline"
page — and declines to respond to everything else, leaving app code and the
signed API on their normal network path. Caching signed, per-user, time-
sensitive responses (or a stale client that verifies them) would trade a clear
offline message for silently wrong balances. Offline re-unlock from a cached
keystore blob is a separate, unbuilt feature (docs/REVIEW.md §18).
Icons: icon.svg is the source of truth (it mirrors the .logo-mark in
styles.css); the PNGs and favicon.ico are rendered from it, and the
full-bleed variants exist because Android masks icons and iOS rounds them.
Key handling & security model
The bank is a blind custodian: it stores only an encrypted keystore blob and never sees the password or the plaintext key.
- Registration (
#/register): an ed25519 keypair is generated in the browser. The 32-byte seed is encrypted with PBKDF2-HMAC-SHA-256 (250,000 iterations, random 16-byte salt) deriving an AES-256-GCM key (random 12-byte nonce). The clientPOSTs{handle, pubkey, keystore, proof}to/:bank/ui/register, whereproofis an ed25519 signature over the canonical form of{handle, pubkey, keystore_sha256}— proving possession of the private key and binding the keystore blob to the registration. See WORKAROUNDS.md §1 for why PBKDF2 rather than Argon2id. - Login (
#/unlock): the encrypted keystore is fetched from the publicGET /:bank/ui/keystore/:handle(bank rate-limits it to 5/min per handle), decrypted locally, and the pubkey derived from the seed must match the registered pubkey. There is no password recovery. - Session lifetime: the decrypted seed is mirrored into
localStorage(keyed by bank name) so the session survives refreshes, tab closes, and PWA restarts; it never leaves the browser.localStoragealso keeps the last handle used. Auto-lock wipes the key (memory + stored entry) after 10 minutes of inactivity (checked every 30 s), and logging out clears both. - Recovery kit (
#/settings): downloads{handle, pubkey, bank, keystore}as JSON — useful only with the password. - Barter Links: landing pages fetch the
?format=jsonenvelope and verify every document signature client-side (verifyDoc) before rendering anything. A foreign bank's link resolves at its origin bank.
Screens
Hash-routed; the whole router is one function in app.js.
| Route | Purpose |
|---|---|
| #/ | Welcome hero (logged out) / Home: balances, quick actions, recent activity, and the Discover section — the follows feed, a gallery of vouchers seen in it, and open offers polled from known banks (accept one into a deal) |
| #/register, #/unlock | Create account / log in with handle + password |
| #/connect | Import a raw 32-byte base58 seed |
| #/vouchers, #/vouchers/new | Voucher tiles in three sections — issued by you, you hold (balances summed per voucher), you follow (trusted issuers) — and minting; share profile QR |
| #/vouchers/:hash | Voucher detail: art, issuer, your position, and every action — trade, invoice, cheque, post, voucher QR — with a link to its feed |
| #/orders, #/orders/new, #/orders/new/:voucher | List orders; author a two-sided swap order. The :voucher form arrives from a post's "Trade for this" with that voucher preselected as what you receive |
| #/invoices, #/invoices/new, #/invoices/new/:voucher | Credit-only orders (requests for payment) with shareable QR; :voucher preselects the voucher to receive |
| #/cheques, #/cheques/new, #/cheques/new/:voucher | Debit-only orders with shareable QR; :voucher preselects the voucher to pay out |
| #/discover | Redirects to #/ — Discover merged into Home |
| #/posts, #/posts/:voucher | Voucher post feeds. Each post offers Reply, Repost, Follow author, and Trade for this — which trusts the voucher's issuer (pinning their bank if foreign) and opens a swap preloaded with it. An issuer composing about their own voucher can tick "update this voucher's look" to release a new icon/square SVG and description. Issuer SVGs render as data: URIs inside <img>, never inlined, so embedded scripts cannot run. Merges list_posts across every trusted author x known bank, newest-first, de-duplicated by content hash; compose, reply and repost; every post's signature tree is verified client-side before it renders |
| #/deal/:id | Deal status with per-leg ready/hold/settle; re-polls every 3 s until settled/rejected |
| #/activity | Transaction history |
| #/network | Following (feed subscriptions, incl. your bank), trusted issuers (with free-text notes), pinned banks, contacts |
| #/scan | Camera QR scanner (BarcodeDetector, jsQR fallback) or paste a link |
| #/settings | Identity, bank info, install on home screen, recovery kit, lock |
| #/land/:kind/:value | Barter Link landings (i profile, v invoice, q cheque, o offer, x invite) — work logged out, then resume the action after register/login |
| #/admin | Operator console (visible only when the logged-in pubkey is in the bank's BANK_ADMINS env config): overview counts, users, holdings, transactions, every post stored at the bank, and a manual "repost as the bank" action for posts the bank is not already carrying |
Order/invoice/cheque forms use a voucher chooser (own issued vouchers plus
trusted issuers' vouchers resolved via the public GET /:bank/ui/resolve/:pubkey)
instead of raw hash pasting.
Loading pattern
Screens that collect data across multiple banks never block first paint on a
peer: the shell renders immediately and each section fills in as its data
arrives (SECTION_SPINNER + fillSection). Local data lands first; pinned
banks merge per bank as they answer (remoteHoldingsProgressive). A slow or
unreachable peer shows a "Checking pinned banks…" note, never a frozen screen.
Browser tests
scripts/ui-test/ holds a Playwright (Python) harness: slowbank.py is a
stub peer that answers everything after 5s, and ui_test.py drives headless
Chromium through register → mint → pin-slow-bank → vouchers/detail/mobile,
asserting that local content paints fast and sections settle independently.
Run a local bank on :8100, then python3 scripts/ui-test/ui_test.py.
Transports
Two signed channels, both authenticated by the user's ed25519 key:
- JSON-RPC —
POST /:bank/rpcwith a signed envelope{jsonrpc, id, method, params, pubkey, to, sig}. This is the protocol surface (bank-rpc). - Signed REST —
/:bank/ui/*with anX-Barter-Authheader:base64url(canonical authdoc) + "." + base58 signature, where the authdoc is{pubkey, method, path, id, ts, body_sha256}(pathincludes the query string). The bank checks method/path match, ±120 s timestamp skew, a single-useid(replay protection), and the body hash.
Note: /:bank/ui/* (state, portfolio, history, orders, discover,
propose_deal, deal status, trusted/banks/contacts, keystore) is this bank's
custom API layer for its own client — an implementation detail, not part
of the protocol contract.
Files
| File | What it is |
|---|---|
| index.html | Shell + import map + icon/manifest links; <base> is injected at serve time |
| app.js | The entire app: router, screens, transports, keystore crypto, install offer |
| styles.css | All styling |
| sw.js | Service worker: installability + offline page, no caching |
| icon.svg | The app mark — source artwork for every raster icon below |
| favicon.ico | 16/32/48 favicon, rendered from icon.svg |
| icon-192.png, icon-512.png | Manifest icons (purpose: any) |
| icon-maskable-512.png | Full-bleed manifest icon (purpose: maskable) for Android's icon mask |
| apple-touch-icon.png | 180×180 opaque home-screen icon for iOS |
| protocol.js | Vendored JS build of packages/protocol/src/index.ts — not imported from the workspace |
| qr.js | QR generation (ECC level M) and camera scanning |
| vendor/qrcode.js | qrcode-generator 1.5.0 (MIT), UMD → ESM |
| vendor/jsqr.js | jsQR 1.4.0 (Apache-2.0), UMD → ESM |
Regenerating protocol.js is a manual step. When the protocol package
changes: npx tsc -p tsconfig.web.json from packages/protocol/ emits
apps/web/index.js; rename it to protocol.js and review the diff. No script
automates this, so the file can drift — treat protocol-package changes as
incomplete until this mirror is refreshed.
Developing
There is no build step, and no meaningful standalone dev server — nearly every screen needs the bank API. Run the bank's local server and let it serve the SPA:
bun run scripts/genkey.ts # prints a fresh BANK_PRIV_KEY=<base58> line
cd apps/bank-aws
BANK_ALICE_PRIV_KEY=<base58 seed> bun run local # Node server on :8100Open http://localhost:8100/alice/ui. The bank name comes from the env var
(BANK_FOO_BAR_PRIV_KEY → bank foo-bar); set several vars to run a local
federation on one port. Files are read from disk per request — edit and reload.
Known gaps
- The deal screen's "Relay signatures" button is a placeholder: it posts
empty
record_hasheswithfrom=to= the user's own bank, so it never relays anything. - Cross-bank order submission from
#/orders/newis unfinished: the order is only submitted to the user's own bank even when the credit voucher lives at another bank. Cross-bank deals work via the discover/landing accept paths, where the counterparty bank is known. - The voucher create form has no
expiresfield, although the protocolVoucherschema supports an optionalexpires. #/chequesis a stub that points at the Orders tab; only#/cheques/newdoes real work.- Keystore KDF is PBKDF2, not Argon2id (WORKAROUNDS.md §1).
