Public: anyone can read this; the crew can change it.

The admin sees orders, payments and stock change live #47

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

ledger/bazaar:ledger/11-the-admin-sees-orders-payments-and-stock → main 5 commits fork 2 threads · 2 unresolved

  • Merged. ledger/11-the-admin-sees-orders-payments-and-stock was merged into main as 70fc345.

Review

Sign in to review this change.
19 files changed +1136 −64
README.md +91 −8
@@ -23,7 +23,9 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
2323 ```
2424
2525 It has no dependencies: Bun's `bun:sqlite`, `node:crypto` and the standard
26 library are all of it. There is no client-side JavaScript.
26 library are all of it. The shop has no client-side JavaScript at all; the
27 admin's three live pages load one small file — see
28 [The admin sees it happen](#the-admin-sees-it-happen).
2729
2830 ## What is real and what is simulated
2931
@@ -270,6 +272,81 @@ for why, and for the one declaration that changes it. Reading a gift card's
270272 balance gets a bucket of its own there, separate from typing a code at the
271273 till: someone reading their card over and over must still be able to spend it.
272274
275 ## The admin sees it happen
276
277 An admin page is true at the instant it was rendered, and an order placed by
278 a stranger a second later would leave it quietly wrong. So the ledger says
279 what it wrote, and the page listens.
280
281 - `src/live.ts` is one bus: `emit(type, data)` writes a `live_event` row, the
282 last hundred of which are kept, and `GET /admin/events` is a server-sent
283 event stream with a `retry:`, a heartbeat every 25 seconds, and a replay of
284 everything after the `Last-Event-ID` (or `?since=`) a reconnecting browser
285 brings with it — row ids, so they still mean something after a restart. The
286 stream is admin-only, and it is the only route this adds. A listener is
287 dropped when the request aborts or the stream is cancelled, so a reload
288 leaves nothing behind, and a connection that has stopped reading is dropped
289 once a couple of hundred frames have queued on it rather than held.
290 - **A stream has a life.** Every other door here proves who is asking on
291 every request; a stream is one request that lasts, so it would otherwise be
292 checked once and then trusted for as long as the socket stayed open — an
293 admin the forge has demoted, or an identity that has expired, going on
294 hearing every order and every figure in cents. So `/admin/events` closes
295 the connection at the earlier of four minutes and the expiry the forge
296 signed into `x-forge-identity`. The browser reconnects by itself after the
297 `retry:` and is checked again on the way back in; `Last-Event-ID` replays
298 the two seconds in between, so a page that is still an admin's loses
299 nothing and one that is not is answered `403` and backs off.
300 - An event is a row and not a message in memory because **the app is two
301 processes over one database**: the web server and the terminal. `emit`
302 writes the row; whichever process is serving streams hands out the rows it
303 has not handed out yet — its own at once, the other process's within a
304 second, both through one path in id order. A connection also remembers the
305 highest id it has been given, so a replay that runs ahead of that cursor —
306 rows the terminal wrote a moment ago — is not said a second time by the
307 poll catching up behind it: an event reaches a page exactly once, and never
308 out of order. The poll runs only while somebody is
309 listening. So stock moved with `s` in the terminal, or an order refunded
310 with `r`, shows up on an open `/admin` in about a second, the same as if it
311 had been done in the browser.
312 - `emit` is called where a ledger row is written and nowhere else: an order
313 placed, an order paid — through the gateway, through a gift card that
314 covered the lot, or through Stripe's webhook, which all end in one
315 `settleFromGateway` — a refund, a cancellation, and a stock movement. Only
316 the branch that actually moves the row to `paid` says `order.paid`: a
317 charge that falls short of what the order owes is written to the ledger and
318 leaves the order awaiting payment, and the admin's screen is told nothing,
319 because a screen that said paid there would be lying about the money. Every call
320 happens **after** the transaction commits. A stock movement made inside a
321 bigger transaction (an order taking its stock) is announced by whoever
322 commits that transaction, because a rollback would otherwise have told an
323 admin about an order that does not exist.
324 - An event says an id, an order number, a figure in cents and a SKU. It
325 never says an address, an email, a gift card code or a gateway reference:
326 the page is where the private things are, and the page is behind the
327 admin's door.
328 - `static/live.js` turns the stream into `live` and `live:<type>` events on
329 `document` and refetches the page it is already on, replacing the regions
330 marked `data-live` by their `id`. One refetch is in flight at a time and
331 the next event coalesces into it, so a busy shop is not a poll. A region
332 someone is typing in is left alone until the caret moves on.
333
334 Live pages: **`/admin`** (the totals and the latest orders), **`/admin/orders`**
335 and **`/admin/inventory`** (both tables — stock on hand and the ledger).
336 Their tables have fixed columns, so a row arriving cannot move the rows
337 around it. No other page loads a script, and none of them has an inline one:
338 the floor's `script-src` is `'self'`, with no `unsafe-inline` and no
339 `unsafe-eval`, and `static/live.js` is served under a name made from its own
340 contents (`/static/live.<hash>.js`) with a year-long cache, the same trick
341 the stylesheet plays. With JavaScript off, every one of those pages is the
342 page it always was — one reload behind.
343
344 The bus is a table, so the terminal (`bun run src/tty.ts`, or `ssh`) is not a
345 blind spot: it is a second process on the same database, its `emit` writes a
346 row like any other, and the web process notices it on its next pass — about a
347 second. Nothing in the app can move stock or money without an open `/admin`
348 saying so.
349
273350 ## Shape
274351
275352 | File | One concept |
@@ -289,7 +366,9 @@ till: someone reading their card over and over must still be able to spend it.
289366 | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox |
290367 | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` |
291368 | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents |
292 | `src/units.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` `src/https.test.ts` | The rules on their own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy, the app booted on an https public address |
369 | `src/live.ts` `static/live.js` | The bus: a `live_event` row, and the stream that hands rows out across both processes; the one script, which refetches the `data-live` regions of the page it is on |
370 | `src/asset.ts` | A file under `static/`, addressed by its contents: the hash in the path has to be the hash of the bytes |
371 | `src/units.test.ts` `src/live.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` `src/https.test.ts` | The rules on their own, the bus on its own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy, the app booted on an https public address |
293372
294373 ### What the door refuses
295374
@@ -305,7 +384,8 @@ is not a form is `415`, one that is not same-origin or is missing the session's
305384 CSRF token is `403`.
306385
307386 The security floor on every response is in `src/server.ts`: a content security
308 policy with `script-src 'none'` and `form-action 'self'` (widened for one
387 policy with `script-src 'self'` — the one loosening this app has made, for the
388 one module under `static/` — and `form-action 'self'` (widened for one
309389 response only when a form hands the shopper to Stripe), `nosniff`,
310390 `strict-origin-when-cross-origin`, a permissions policy, and — only when
311391 `FORGE_APP_URL` is https, as it is on the forge — `strict-transport-security:
@@ -325,11 +405,14 @@ the suite passes in any order — `bun test`, `bun test --isolate`,
325405
326406 ## The terminal
327407
328 The same shop over the same data. The exact command is in `FORGE_APP_SSH`
329 when the app runs; the footer of every page prints it verbatim, and says the
330 terminal is off when it is unset. `1`–`7` switch between stock, orders, the
331 outbox, discount codes, gift cards, the tax table and the changes an admin
332 made to any of it — everything `/admin`
408 The same shop over the same data, in a second process. The exact command is
409 in `FORGE_APP_SSH` when the app runs; the footer of every page prints it
410 verbatim, and says the terminal is off when it is unset. What is typed here
411 is written to the same database, and — because an event is a row in it — a
412 stock move or a refund made here reaches an admin's open `/admin` over the
413 live stream within about a second, not on that page's next load. `1`–`7`
414 switch between stock, orders, the outbox, discount codes, gift cards, the tax
415 table and the changes an admin made to any of it — everything `/admin`
333416 can see — and `j`/`k` move. A tab whose rows are the shop's own business is an
334417 admin's to open: a non-admin gets stock and tax, the other five are marked
335418 with a `·` in the tab bar and answer "That is an admin's to do." without
src/admin.ts +28 −5
@@ -14,15 +14,16 @@ import * as giftcards from "./giftcards.ts";
1414 import * as tax from "./tax.ts";
1515 import * as gateway from "./gateway.ts";
1616 import * as inventory from "./inventory.ts";
17 import * as live from "./live.ts";
1718 import * as orders from "./orders.ts";
1819 import * as pages from "./pages/admin.ts";
1920 import { db, type Variant } from "./db.ts";
20 import { field, intField, redirect } from "./http.ts";
21 import { field, intField, redirect, text } from "./http.ts";
2122 import { forOrder, outbox } from "./mail.ts";
2223 import { formatCents, parseCents } from "./money.ts";
2324 import { render, type Ctx } from "./context.ts";
2425 import { notFound } from "./shop.ts";
25 import { identityConfigured, returnTo } from "./who.ts";
26 import { expiresAt, identityConfigured, returnTo } from "./who.ts";
2627
2728 const MESSAGES: Record<string, { text: string; bad?: boolean }> = {
2829 saved: { text: "Saved." },
@@ -100,7 +101,29 @@ export function dashboard(ctx: Ctx): Response {
100101 (SELECT COUNT(*) FROM product) AS products`,
101102 )
102103 .get()!;
103 return render(ctx, { title: "Admin", admin: true }, pages.overview({ ...numbers, ...counts }, orders.recent(10)));
104 return render(
105 ctx,
106 { title: "Admin", admin: true, live: true },
107 pages.overview({ ...numbers, ...counts }, orders.recent(10)),
108 );
109 }
110
111 /**
112 * The one stream. It is admin-only like every page here, and it answers in
113 * plain text rather than a page: whatever opened it is a script, not a
114 * reader. Everything it carries was emitted where a ledger row was written.
115 *
116 * A page is checked on every request; a stream would be checked once and then
117 * trusted for as long as the socket lived, which is how an admin the forge
118 * has demoted or an identity that has expired would go on hearing every order
119 * and every figure in cents. So the connection is given an end — the earlier
120 * of a few minutes and the expiry the forge signed — and the browser comes
121 * back through this very line to be checked again, replaying the gap with
122 * `Last-Event-ID` so nothing is missed on the way.
123 */
124 export function events(ctx: Ctx): Response {
125 if (ctx.who.role !== "admin") return text("forbidden", 403);
126 return live.events(ctx.request, Math.min(Date.now() + live.MAX_LIFE_MS, expiresAt(ctx.request.headers)));
104127 }
105128
106129 // --- products ---------------------------------------------------------------
@@ -269,7 +292,7 @@ export function inventoryList(ctx: Ctx): Response {
269292 .all();
270293 return render(
271294 ctx,
272 { title: "Inventory", admin: true },
295 { title: "Inventory", admin: true, live: true },
273296 pages.inventory(ctx.session, rows, inventory.history(40), messageFrom(ctx)),
274297 );
275298 }
@@ -294,7 +317,7 @@ export function orderList(ctx: Ctx): Response {
294317 const blocked = guard(ctx);
295318 if (blocked) return blocked;
296319 const query = (ctx.url.searchParams.get("q") ?? "").slice(0, 80);
297 return render(ctx, { title: "Orders", admin: true }, pages.orders(orders.recent(100, query), query));
320 return render(ctx, { title: "Orders", admin: true, live: true }, pages.orders(orders.recent(100, query), query));
298321 }
299322
300323 export function orderDetail(ctx: Ctx): Response {
src/asset.ts added +54
@@ -0,0 +1,54 @@
1 /**
2 * A file under static/, addressed by its own contents.
3 *
4 * `pathOf("live.js")` is `/static/live.<hash>.js`, where the hash is of the
5 * bytes: change the file and the address changes, so the year the browser
6 * keeps it for is never a year of staleness. It is the trick src/style.ts
7 * plays on the stylesheet, for the things that are files on disk.
8 *
9 * `fileOf` is the reverse, and it is strict on purpose: a path whose hash is
10 * not the one the file has now is not found at all, rather than answered with
11 * bytes a browser would then hold for a year under the wrong name.
12 */
13
14 import { createHash } from "node:crypto";
15 import { existsSync, readFileSync } from "node:fs";
16 import { join } from "node:path";
17
18 const DIR = join(import.meta.dir, "..", "static");
19
20 /** `live.<hash>.js` and nothing else: no directory, no `..`, no third dot. */
21 const ADDRESS = /^\/static\/([a-z0-9-]{1,40})\.([0-9a-f]{8})\.(css|js)$/;
22
23 /** Files do not change under a running process, so each is hashed once. */
24 const hashes = new Map<string, string>();
25
26 function hashOf(name: string): string | null {
27 const known = hashes.get(name);
28 if (known !== undefined) return known;
29 const where = join(DIR, name);
30 if (!existsSync(where)) return null;
31 const digest = createHash("sha256").update(readFileSync(where)).digest("hex").slice(0, 8);
32 hashes.set(name, digest);
33 return digest;
34 }
35
36 /**
37 * The address a page links. A name with no file behind it links the plain
38 * name — that is a missing file in the repository, and it should show up as
39 * one 404 rather than as a crash at boot.
40 */
41 export function pathOf(name: string): string {
42 const hash = hashOf(name);
43 if (hash === null) return `/static/${name}`;
44 const dot = name.lastIndexOf(".");
45 return `/static/${name.slice(0, dot)}.${hash}${name.slice(dot)}`;
46 }
47
48 /** The file a `/static/` path means, or null when it means nothing of ours. */
49 export function fileOf(pathname: string): string | null {
50 const parts = ADDRESS.exec(pathname);
51 if (!parts) return null;
52 const name = `${parts[1]}.${parts[3]}`;
53 return hashOf(name) === parts[2] ? join(DIR, name) : null;
54 }
src/context.ts +2 −1
@@ -47,7 +47,7 @@ export const cartCount = (cart: CartLine[]): number => cart.reduce((n, line) =>
4747
4848 export function render(
4949 ctx: Ctx,
50 options: { title: string; current?: string; query?: string; admin?: boolean; status?: number },
50 options: { title: string; current?: string; query?: string; admin?: boolean; live?: boolean; status?: number },
5151 body: string,
5252 ): Response {
5353 return html(
@@ -60,6 +60,7 @@ export function render(
6060 current: options.current,
6161 query: options.query,
6262 admin: options.admin,
63 live: options.live,
6364 body,
6465 }),
6566 options.status ?? 200,
src/db.ts +16 −1
@@ -6,7 +6,8 @@
66 * balance or an order leaves a row behind saying why (stock_entry, payment,
77 * gift_card_entry, discount_redemption, order_event). A product's stock and a
88 * gift card's balance are the running totals of their ledgers, never a bare
9 * edit.
9 * edit. `live_event` is the youngest of those rows: what changed, said once,
10 * so the admin pages can follow it across both of the app's processes.
1011 *
1112 * `open(dir)` is the whole of it: a directory in, a usable database out. The
1213 * app keeps one of those — `db`, under FORGE_APP_DATA — and a test keeps its
@@ -232,6 +233,20 @@ CREATE TABLE IF NOT EXISTS tax_rate (
232233 active INTEGER NOT NULL DEFAULT 1
233234 );
234235 CREATE UNIQUE INDEX IF NOT EXISTS tax_rate_place ON tax_rate(country, region);
236
237 -- What changed, for the admin pages that follow it: one row per event, the
238 -- last hundred kept. It is a table and not a queue in memory because the app
239 -- is two processes over one database — the web server and the terminal — and
240 -- a stock move typed at the ssh door has to reach a page the web server is
241 -- streaming to. The id is the event id a browser reconnects with, so it
242 -- outlives a restart. The data column is small JSON: see src/live.ts for what
243 -- may be in it, which is never anything a customer owns.
244 CREATE TABLE IF NOT EXISTS live_event (
245 id INTEGER PRIMARY KEY,
246 type TEXT NOT NULL,
247 data TEXT NOT NULL DEFAULT 'null',
248 at TEXT NOT NULL DEFAULT (datetime('now'))
249 );
235250 `;
236251
237252 /**
src/html.ts +12 −4
@@ -2,12 +2,14 @@
22 * HTML: escaping, and the shell every page is poured into.
33 *
44 * Nothing here interpolates a value that has not been through `escape`, and
5 * every form carries the session's CSRF token. There is no client-side
6 * JavaScript in this store — the shell links a single stylesheet and the
7 * browser does the rest.
5 * every form carries the session's CSRF token. The shop has no client-side
6 * JavaScript at all — the shell links a single stylesheet and the browser
7 * does the rest. The admin pages with live regions on them link one file,
8 * static/live.js, and nothing anywhere is an inline script.
89 */
910
1011 import { CSS_PATH } from "./style.ts";
12 import { pathOf } from "./asset.ts";
1113 import { FORGE_LINK_ATTRS, SIGN_OUT_HREF, signInHref } from "./who.ts";
1214 import type { Session } from "./session.ts";
1315 import type { Who } from "./who.ts";
@@ -50,6 +52,8 @@ export type Chrome = {
5052 current?: string;
5153 query?: string;
5254 admin?: boolean;
55 /** Set on the admin pages that have `data-live` regions, and nowhere else. */
56 live?: boolean;
5357 body: string;
5458 };
5559
@@ -70,7 +74,11 @@ export function layout(chrome: Chrome): string {
7074 <meta charset="utf-8">
7175 <meta name="viewport" content="width=device-width, initial-scale=1">
7276 <title>${escape(chrome.title)} · bazaar</title>
73 <link rel="stylesheet" href="${CSS_PATH}">
77 <link rel="stylesheet" href="${CSS_PATH}">${
78 chrome.live
79 ? `\n<script type="module" src="${escape(pathOf("live.js"))}"></script>`
80 : ""
81 }
7482 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🛍️</text></svg>">
7583 <body class="${chrome.admin ? "admin" : ""}">
7684 <header class="top"><div class="wrap">
src/inventory.ts +23 −7
@@ -6,6 +6,7 @@
66 */
77
88 import { db } from "./db.ts";
9 import { emit } from "./live.ts";
910
1011 export type StockEntry = {
1112 id: number;
@@ -28,6 +29,18 @@ const record = (variantId: number, delta: number, reason: string, orderId?: numb
2829 );
2930 };
3031
32 /**
33 * A move that has happened tells the admin's stream about it — the SKU and
34 * nothing else. Inside a bigger transaction, which is how an order takes its
35 * stock, there is nothing to announce yet: that row is not real until the
36 * transaction around it commits, so whoever commits it does the announcing.
37 */
38 const announce = (variantId: number): void => {
39 if (db.inTransaction) return;
40 const row = db.query<{ sku: string }, [number]>("SELECT sku FROM variant WHERE id = ?1").get(variantId);
41 if (row) emit("stock.moved", { id: variantId, sku: row.sku });
42 };
43
3144 /** Add stock (a delivery, a correction, a restock after a refund). */
3245 export function addStock(variantId: number, quantity: number, reason: string, orderId?: number): void {
3346 if (quantity <= 0) return;
@@ -35,6 +48,7 @@ export function addStock(variantId: number, quantity: number, reason: string, or
3548 db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, quantity);
3649 record(variantId, quantity, reason, orderId);
3750 })();
51 announce(variantId);
3852 }
3953
4054 /**
@@ -43,7 +57,7 @@ export function addStock(variantId: number, quantity: number, reason: string, or
4357 */
4458 export function takeStock(variantId: number, quantity: number, reason: string, orderId?: number): boolean {
4559 if (quantity <= 0) return true;
46 return db.transaction(() => {
60 const took = db.transaction(() => {
4761 const changes = db
4862 .query("UPDATE variant SET stock = stock - ?2 WHERE id = ?1 AND stock >= ?2")
4963 .run(variantId, quantity).changes;
@@ -51,6 +65,8 @@ export function takeStock(variantId: number, quantity: number, reason: string, o
5165 record(variantId, -quantity, reason, orderId);
5266 return true;
5367 })();
68 if (took) announce(variantId);
69 return took;
5470 }
5571
5672 /** Set stock to a number by writing the difference as a correction. */
@@ -59,12 +75,12 @@ export function setStock(variantId: number, target: number, reason: string): voi
5975 if (!current) return;
6076 const delta = target - current.stock;
6177 if (delta === 0) return;
62 if (delta > 0) addStock(variantId, delta, reason);
63 else
64 db.transaction(() => {
65 db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, delta);
66 record(variantId, delta, reason);
67 })();
78 if (delta > 0) return addStock(variantId, delta, reason);
79 db.transaction(() => {
80 db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, delta);
81 record(variantId, delta, reason);
82 })();
83 announce(variantId);
6884 }
6985
7086 export const history = (limit = 50): StockEntry[] =>
src/live.test.ts added +235
@@ -0,0 +1,235 @@
1 /**
2 * The bus, on its own: numbering, the replay, the listeners, and the row in
3 * the database that lets a second process be heard.
4 *
5 * The ids in these tests are the ids `emit` hands back, never numbers written
6 * down here.
7 */
8
9 import { expect, test } from "bun:test";
10 import { join } from "node:path";
11 import { freshData } from "./fresh.ts";
12
13 // A database of this file's own: an event is a row, so this file has rows.
14 const dataDir = await freshData("live");
15
16 const { emit, events, subscribe } = await import("./live.ts");
17 const { db } = await import("./db.ts");
18
19 /**
20 * The row another process's `emit` leaves behind, without the process: the
21 * table is the whole of what one writes, and the spawn test below proves the
22 * real thing. Written this way, a test can be sure the poll has not reached
23 * it yet.
24 */
25 const elsewhere = (type: string, data: unknown): number =>
26 db
27 .query<{ id: number }, [string, string]>("INSERT INTO live_event (type, data) VALUES (?1, ?2) RETURNING id")
28 .get(type, JSON.stringify(data))!.id;
29
30 /** A reader over an open stream, one `\n\n` frame at a time. */
31 function open(response: Response) {
32 const reader = response.body!.getReader();
33 const decoder = new TextDecoder();
34 // The pending read is kept across the waits: a read that loses the race is
35 // still the read the next chunk goes to, so dropping it would drop data.
36 let pending: Promise<ReadableStreamReadResult<Uint8Array>> | null = null;
37 let buffer = "";
38 let ended = false;
39 return {
40 /** The next frame, or null when nothing comes within `ms`. */
41 async next(ms = 300): Promise<string | null> {
42 for (;;) {
43 const end = buffer.indexOf("\n\n");
44 if (end >= 0) {
45 const frame = buffer.slice(0, end);
46 buffer = buffer.slice(end + 2);
47 return frame;
48 }
49 if (ended) return null;
50 pending ??= reader.read();
51 const step = await Promise.race([pending, Bun.sleep(ms).then(() => "waited" as const)]);
52 if (step === "waited") return null;
53 pending = null;
54 if (step.done) ended = true;
55 else buffer += decoder.decode(step.value, { stream: true });
56 }
57 },
58 /** True once the stream itself has ended, rather than merely gone quiet. */
59 isEnded: () => ended,
60 close: () => reader.cancel(),
61 };
62 }
63
64 const request = (query = "", headers: Record<string, string> = {}): Request =>
65 new Request(`http://bazaar.test/admin/events${query}`, { headers });
66
67 /** The `{ type, data }` a frame carries, with the id it was numbered with. */
68 const frame = (text: string): { id: number; type: string; data: any } => {
69 const id = Number(/^id: (\d+)$/m.exec(text)![1]);
70 return { id, ...JSON.parse(/^data: (.*)$/m.exec(text)![1]!) };
71 };
72
73 test("a stream says how to retry, then follows what is emitted", async () => {
74 const before = emit("test.before", { id: 1 });
75 const response = events(request(`?since=${before.id}`));
76 expect(response.headers.get("content-type")).toBe("text/event-stream");
77 expect(response.headers.get("cache-control")).toBe("no-store");
78 const stream = open(response);
79
80 // The first thing a browser hears is how long to wait before reconnecting.
81 expect(await stream.next()).toBe("retry: 2000");
82 // Nothing from before `since` is replayed.
83 expect(await stream.next()).toBe(null);
84
85 const after = emit("test.after", { id: 2, sku: "COF-GUJI-250" });
86 const heard = frame((await stream.next())!);
87 expect(heard).toEqual({ id: after.id, type: "test.after", data: { id: 2, sku: "COF-GUJI-250" } });
88 expect(await stream.next()).toBe(null);
89 await stream.close();
90 });
91
92 test("a reconnect with Last-Event-ID replays what was missed and nothing else", async () => {
93 const one = emit("test.one", { id: 1 });
94 const two = emit("test.two", { id: 2 });
95 const three = emit("test.three", { id: 3 });
96
97 const stream = open(events(request("", { "last-event-id": String(one.id) })));
98 expect(await stream.next()).toBe("retry: 2000");
99 expect(frame((await stream.next())!).id).toBe(two.id);
100 expect(frame((await stream.next())!).id).toBe(three.id);
101 expect(await stream.next()).toBe(null);
102 await stream.close();
103 });
104
105 test("the last hundred events are kept, and no more", async () => {
106 const mine = [];
107 for (let n = 0; n < 120; n++) mine.push(emit("test.many", { id: n }));
108
109 const stream = open(events(request("?since=0")));
110 expect(await stream.next()).toBe("retry: 2000");
111 const replayed = [];
112 for (;;) {
113 const next = await stream.next();
114 if (next === null) break;
115 replayed.push(frame(next));
116 }
117 expect(replayed.length).toBe(100);
118 // The hundred kept are the last hundred: the first twenty are gone.
119 expect(replayed[0]!.id).toBe(mine[20]!.id);
120 expect(replayed[99]!.id).toBe(mine[119]!.id);
121 await stream.close();
122 });
123
124 test("a listener hears what is emitted until it unsubscribes", () => {
125 const heard: string[] = [];
126 const unsubscribe = subscribe((event) => heard.push(event.type));
127 emit("test.heard", {});
128 unsubscribe();
129 emit("test.unheard", {});
130 expect(heard).toEqual(["test.heard"]);
131 });
132
133 test("an aborted request takes its listener with it", async () => {
134 const stop = new AbortController();
135 const here = emit("test.here", {});
136 const stream = open(
137 events(new Request(`http://bazaar.test/admin/events?since=${here.id}`, { signal: stop.signal })),
138 );
139 expect(await stream.next()).toBe("retry: 2000");
140
141 stop.abort();
142 // The stream is closed, so what is emitted next reaches nobody through it
143 // — a reload that leaves a listener behind would leak one per reload.
144 emit("test.after-abort", {});
145 expect(await stream.next()).toBe(null);
146 expect(stream.isEnded()).toBe(true);
147 });
148
149 test("a connection that has stopped reading is dropped, not queued for", async () => {
150 const here = emit("test.wedged", {});
151 const response = events(request(`?since=${here.id}`));
152 // Subscribed, and never read from: every frame below is queued on it.
153 for (let n = 0; n < 400; n++) emit("test.flood", { id: n });
154
155 const stream = open(response);
156 let frames = 0;
157 while ((await stream.next()) !== null) frames++;
158 expect(stream.isEnded()).toBe(true);
159 expect(frames).toBeLessThan(400);
160 });
161
162 /**
163 * The reason an event is a row. `s` and `r` in the terminal move stock and
164 * refund orders through the same functions the web handlers use, and they
165 * run in another process: its `emit` writes a row here, and the process
166 * holding the streams has to notice it.
167 */
168 test("a row written by a second process reaches an open stream", async () => {
169 const heard: unknown[] = [];
170 const unsubscribe = subscribe((event) => heard.push({ type: event.type, data: event.data }));
171 try {
172 const wrote = Bun.spawnSync(
173 ["bun", "-e", 'const { emit } = await import("./src/live.ts"); emit("stock.moved", { id: 7, sku: "FROM-ELSEWHERE" });'],
174 { cwd: join(import.meta.dir, ".."), env: { ...process.env, FORGE_APP_DATA: dataDir }, stderr: "pipe" },
175 );
176 expect(wrote.exitCode).toBe(0);
177 expect(heard).toEqual([]); // Not ours to hear yet: it is in the table.
178
179 const deadline = Date.now() + 5000;
180 while (heard.length === 0 && Date.now() < deadline) await Bun.sleep(50);
181 expect(heard).toEqual([{ type: "stock.moved", data: { id: 7, sku: "FROM-ELSEWHERE" } }]);
182 } finally {
183 unsubscribe();
184 }
185 });
186
187 /**
188 * A stream is the one request in this app that lasts, so it is the one that
189 * could go on talking to somebody the forge has stopped vouching for. It is
190 * given an end instead; the browser reconnects through the route's role
191 * check, which is where an expired or demoted admin is turned away.
192 */
193 test("a stream is given an end, and says nothing after it", async () => {
194 const here = emit("test.before-the-end", {});
195 const stream = open(events(request(`?since=${here.id}`), Date.now() + 200));
196 expect(await stream.next()).toBe("retry: 2000");
197
198 await Bun.sleep(400);
199 emit("test.after-the-end", { id: 1 });
200 expect(await stream.next()).toBe(null);
201 expect(stream.isEnded()).toBe(true);
202 });
203
204 test("a replay that runs ahead of the poll is not repeated by it", async () => {
205 // Somebody is listening, so the poll is running and its cursor is here.
206 const watching = open(events(request(`?since=${emit("test.mark", {}).id}`)));
207 expect(await watching.next()).toBe("retry: 2000");
208
209 // A row the poll has not reached yet, and a connection opened in the same
210 // turn: its replay hands out an id above the cursor the poll will catch up
211 // to a second later.
212 const row = elsewhere("test.from-elsewhere", { id: 7 });
213 const late = open(events(request(`?since=${row - 1}`)));
214 expect(await late.next()).toBe("retry: 2000");
215 expect(frame((await late.next())!).id).toBe(row);
216
217 await Bun.sleep(1300); // A pass of the poll: it hands that row to everybody.
218 expect(frame((await watching.next())!).id).toBe(row); // The first time, here.
219 expect(await late.next()).toBe(null); // The second time, there: not twice.
220 await watching.close();
221 await late.close();
222 });
223
224 test("an event this process wrote is delivered once, not again by the poll", async () => {
225 const heard: number[] = [];
226 const unsubscribe = subscribe((event) => heard.push(event.id));
227 try {
228 const one = emit("test.once", {});
229 expect(heard).toEqual([one.id]); // At once: no waiting for our own write.
230 await Bun.sleep(1300); // Longer than a pass of the poll.
231 expect(heard).toEqual([one.id]);
232 } finally {
233 unsubscribe();
234 }
235 });
src/live.ts added +217
@@ -0,0 +1,217 @@
1 /**
2 * Realtime without a framework. The ledger `emit`s what changed; the stream
3 * behind `GET /admin/events` sends it to every open admin page as server-sent
4 * events, numbered, with the last hundred kept so a page that reconnects with
5 * `Last-Event-ID` (or `?since=`) hears what it missed. `static/live.js` turns
6 * the stream into DOM events and refetches the regions marked `data-live`.
7 *
8 * An event is a row in `live_event`, not a message in one process's memory.
9 * This app is two processes over one database — the web server and the
10 * terminal — and a stock move typed at the ssh door has to reach an open
11 * /admin as surely as one clicked in it. So `emit` writes the row, and
12 * whichever process is serving streams hands out the rows it has not handed
outdated

Changes requested — one gap. The rest of this is careful work and should stay as it is.

What I did: ran the suite at 30bac3e (96 pass / 0 fail, and green under --randomize --seed 7, --isolate and --parallel=4), booted it locally with a signed admin identity, held a stream open for 75 s (heartbeats at 25 s and 50 s arrive, so idleTimeout: 60 holds), and used the preview as a guest: /admin/events answers 403 forbidden in plain text to anon and to user, /admin and /admin/inventory are 403, /static/live.js and /static/live.00000000.js are both 404 and only the content-addressed name answers, and the floor is script-src 'self' with the policy's single 'unsafe-inline' still on style-src.

High — a write in the terminal reaches nobody, and the page that looks live gives no sign it has gone stale

Locally, with an admin holding /admin/events open:

  • a stock move through POST /admin/inventory → id: 1 / data: {"type":"stock.moved","data":{"id":12,"sku":"BRW-GRIND-2"}}. Good.
  • the same move from a second process — addStock(12, 3, "terminal probe"), which is exactly what the s key does in src/tty.ts — writes its ledger row and the stream stays silent. /admin/inventory keeps showing the old number until a person reloads.

src/tty.ts moves stock (s) and refunds orders (r) through the very functions that now emit, but it is a second process, so its emit fans out to a listener set that nobody in the web process is in. The README is honest about this, which I credit, but "the admin sees stock and orders change live as they happen" is the whole pull: today an admin at the ssh door silently desynchronises every open admin page, and a page that refreshes itself is trusted more than a page that does not. That is the failure this feature exists to remove, made worse rather than better for that one door.

Fix this first. It keeps one bus, adds no dependency, and it is small: make emit durable and let whoever serves the stream read it.

  • a live_event row — id INTEGER PRIMARY KEY, type TEXT, data TEXT, at TEXT — written by emit in its own small transaction, after the caller's has committed exactly as now;
  • in src/live.ts, when the first listener subscribes, a setInterval (1 s is plenty for a shop this size; unref it so bun test and the tty do not hang on it) that selects rows above the highest id already fanned out and delivers them, cleared when the last listener leaves. Trim the table the way kept is trimmed.

That also makes Last-Event-ID/?since= row ids that survive a restart, and it puts the event trail in the audit story the rest of the app already tells. Two tests would pin it: a row written by a second process reaches an open stream, and one written by this process is delivered exactly once (not twice — once by the fan-out and again by the poll).

Smaller, not blocking

  • src/live.ts: controller.enqueue is unbounded. A wedged admin connection queues every event until the process gives up. Dropping the connection past a few hundred queued events is enough — EventSource reconnects and replays.
  • src/server.ts: idleTimeout: 60 is the whole server's, not the stream's; every connection now gets six times as long to say nothing. Fine given the stream needs it and the forge is in front, but say that in the comment — it reads today as if it were only about the stream.
  • The forge's pictures at this head are both the shop's home page, so no eyes have been on the live regions rendering. I read the markup instead (admin-totals, latest-orders, order-list, stock-on-hand, stock-ledger, fixed colgroups) and it is sound.

What is right and should not change under review

emit after the commit and only where a ledger row is written, with the in-transaction stock move left to whoever commits it, so a rollback tells nobody. One event per charge through took.charged, so a webhook and a browser returning together still say it once. Payloads of id, number, cents and SKU, with a test that asserts the key set and that no email, address or gateway reference travels. /admin/events gated before a stream is allocated, and a plain-text forbidden rather than a page. And src/asset.ts refusing a stale hash instead of serving year-cached bytes under the wrong name is stricter than the /static/ branch it replaced.

13 * out yet: its own at once, anybody else's within a second. One path for
14 * both, in id order; a connection also remembers the highest id it has been
15 * given, so a replay that runs ahead of that cursor is not repeated by the
16 * poll behind it, and an event reaches a page exactly once. The row ids are
17 * the event ids, so `Last-Event-ID` still means something after a restart.
18 *
19 * A stream is the one request in this app that lasts, so it is the one that
20 * could outlive the identity it was opened with. It cannot: `events` takes
21 * the moment it must end, and the route hands it the earlier of a few
22 * minutes and the expiry the forge signed.
23 *
24 * This is the bus and nothing else: it knows no orders, no stock and no
25 * pages. What an event may say is decided where it is emitted — an id, an
26 * order number, a figure in cents, a SKU, and nothing a stranger owns.
27 */
28
29 import { db } from "./db.ts";
30
31 export interface LiveEvent {
32 readonly id: number;
33 readonly type: string;
34 readonly data: unknown;
35 }
36
37 /** How many rows are kept: the replay a reconnecting page can be given. */
38 const KEEP = 100;
39 const HEARTBEAT_MS = 25_000;
40 /**
41 * The longest a connection lives before the browser has to ask again. Every
42 * other door in this app proves who is asking on every request; a stream is
43 * one request that would otherwise last all day, so it is given an end. The
44 * `retry:` below brings the EventSource back through the route's role check
45 * with a fresh `x-forge-identity`, and `Last-Event-ID` replays the two
46 * seconds in between, so a page that is still allowed loses nothing.
47 */
48 export const MAX_LIFE_MS = 4 * 60_000;
49 /** How long another process's row waits to be noticed, at the very worst. */
50 const POLL_MS = 1_000;
51 /** Frames a connection may fall behind by before it is dropped. */
52 const MAX_QUEUED = 200;
53
54 const listeners = new Set<(event: LiveEvent) => void>();
55 /** The highest id handed to the listeners. Rows above it are theirs to hear. */
56 let handedOut = 0;
57 let poll: ReturnType<typeof setInterval> | null = null;
58
59 type Row = { id: number; type: string; data: string };
60
61 /** A row's payload. A row we cannot read is an event with no detail, not a crash. */
62 const payload = (data: string): unknown => {
63 try {
64 return JSON.parse(data);
65 } catch {
66 return null;
67 }
68 };
69
70 const newest = (): number =>
71 db.query<{ id: number }, []>("SELECT COALESCE(MAX(id), 0) AS id FROM live_event").get()!.id;
72
73 /** What a page that last heard `id` missed: at most the hundred kept. */
74 const since = (id: number): LiveEvent[] =>
75 db
76 .query<Row, [number]>("SELECT id, type, data FROM live_event WHERE id > ?1 ORDER BY id")
77 .all(id)
78 .map((row) => ({ id: row.id, type: row.type, data: payload(row.data) }));
79
80 /**
81 * Say what changed. Called after the transaction that wrote the ledger row
82 * committed, never inside it: this is a row of its own, and an order that
83 * rolled back must not leave an event behind saying it happened.
84 *
85 * Saying so is best effort, and deliberately: the thing this is about is
86 * already committed, so a row that cannot be written is logged and the admin
87 * is left one reload behind — it is not a reason to fail a checkout that
88 * has already taken the stock. The returned event carries id 0 then.
89 */
90 export function emit(type: string, data: unknown): LiveEvent {
91 let id = 0;
92 try {
93 id = db
94 .query<{ id: number }, [string, string]>(
95 "INSERT INTO live_event (type, data) VALUES (?1, ?2) RETURNING id",
96 )
97 .get(type, JSON.stringify(data) ?? "null")!.id;
98 db.query("DELETE FROM live_event WHERE id <= ?1").run(id - KEEP);
99 } catch (error) {
100 console.error(`live: ${type} could not be written:`, error);
101 }
102 if (id !== 0 && listeners.size > 0) handOut();
103 return { id, type, data };
104 }
105
106 /**
107 * Hand out every row written since the last one handed out, in id order.
108 * Both kinds of write come through here — this process's own and another
109 * process's, noticed by the poll — so nothing is delivered twice and
110 * nothing arrives before something older than it.
111 */
112 function handOut(): void {
113 // A cursor above the newest row is a cursor into a database that is not
114 // there any more — a preview reset, or a test file opening its own. Start
115 // from what is in front of us rather than never speaking again.
116 if (newest() < handedOut) handedOut = newest();
117 for (const event of since(handedOut)) {
118 handedOut = event.id;
119 for (const listen of listeners) listen(event);
120 }
121 }
122
123 export function subscribe(listen: (event: LiveEvent) => void): () => void {
124 if (listeners.size === 0) {
125 // Nobody was listening, so nothing already written is owed to anybody:
126 // a stream replays what it missed from the table itself. The poll runs
127 // only while somebody is there, and is unref'd so it holds no process
128 // open — a test or the terminal must be able to simply end.
129 handedOut = newest();
130 poll = setInterval(handOut, POLL_MS);
131 poll.unref?.();
132 }
133 listeners.add(listen);
outdated

Changes requested — one finding, and it is new in this round. The terminal gap I raised last time is properly fixed and the fix is good work.

What I did at 5f67a05: ran the suite (99 pass / 0 fail, and green under --randomize, --isolate, --parallel=4); booted it locally with a signed admin identity and confirmed the thing this round was about — addStock(1, 3, "terminal probe") from a second process against the same FORGE_APP_DATA reaches an already-open /admin/events as id: 1 / {"type":"stock.moved","data":{"id":1,"sku":"GFT-CARD-25"}} inside a second. /admin renders the five data-live regions and exactly one <script type="module" src="/static/live.9752f03e.js">. On the preview: home is 200 in ~120 ms, the floor is script-src 'self' with the policy's single 'unsafe-inline' still on style-src, and a guest gets 403 forbidden in plain text from /admin/events, 403 from /admin, 404 from /static/live.js.

High — the stream is authorized once and then trusted for as long as the socket lives

Every other door in this app verifies the forge's signature and its expiry on every request; whoIs is explicit that an expiry that has passed is anonymous. A stream is checked once, at connect, and then the 25 s heartbeat keeps the connection alive forever. Reproduced locally with an identity that expires six seconds after it is minted:

=== after expiry, the page door:
/admin -> 403
/admin/events (new connection) -> 403
=== writing an event 10s after the identity expired:
=== what the already-open stream received:
retry: 2000

id: 1
data: {"type":"stock.moved","data":{"id":1,"sku":"GFT-CARD-25"}}

Ten seconds past expiry the page door is shut and the stream is still feeding. The same holds for an admin the forge demotes: the pages 403 at once (and live.js quietly drops a non-ok refetch), while the open connection keeps announcing every order placed, every figure in cents and every SKU that moves, indefinitely. Nothing a customer owns travels — that part is right and tested — but this is the shop's order flow going to an identity the forge has stopped vouching for, through the one door in the app that is verified once instead of every time. It is new with this pull, because before it there was no long-lived connection here.

Fix this first, and it is small because the replay you already built makes it lossless: give the stream a maximum life — four or five minutes — after which stop() closes it. EventSource reconnects on its own after the retry: 2000 you already send, that reconnect goes through admin.events and is checked against a fresh x-forge-identity, and Last-Event-ID replays the two seconds in between, so a live page loses nothing and an expired one is shut out on its next breath. A test pins it the way the others do: an identity whose expiresMs has passed gets nothing more on a stream it opened while it was valid.

Also needed before this lands

src/orders.ts conflicts with main and the conflict is on the money path: #37 landed after your base and replaced markPaid with settleFromGateway, where a charge that does not cover what the order owes is written to the ledger but leaves the order awaiting payment. Resolve it so emit("order.paid", …) fires only on the branch that actually marks the order paid — a short payment must announce nothing, or the admin's screen says paid when the ledger says otherwise. README.md conflicts too. Re-run the suite after the rebase; bun test on main covers the short-payment rule.

Smaller, not blocking

  • handOut delivers rows above handedOut, and events() replays rows above the client's Last-Event-ID. A reconnect whose id is ahead of handedOut — rows the terminal wrote that the 1 s pass has not reached yet — hears those rows twice: once in its replay, once from the poll. Harmless, because live.js coalesces into one refetch, but the module comment promises "exactly once" and it is exactly once only for the ids this process has already handed out. Either narrow the comment or start the poll's cursor at the highest id any stream has replayed.
  • The forge's pictures at this head are both the shop's home page again, so still no eyes on the live regions. I rendered /admin and /admin/inventory locally instead: the colgroups and table.steady do what they claim, and the phone width holds.

What is right and should not change under review

The row-backed bus: one handOut path in id order, the poll only while somebody is listening and unref'd, the cursor reset when the database underneath has gone, emit best effort so a checkout never fails for want of an announcement, and the ids being row ids so Last-Event-ID survives a restart. emit after the commit and only where a ledger row is written, with an in-transaction stock move left to whoever commits it. One event per charge through took.charged. The payload discipline, asserted by key. src/asset.ts refusing a stale hash rather than serving bytes a browser would hold for a year. And script-src moving exactly one notch, with the count assertion on 'unsafe-inline' to keep it there.

134 return () => {
135 listeners.delete(listen);
136 if (listeners.size === 0 && poll !== null) {
137 clearInterval(poll);
138 poll = null;
139 }
140 };
141 }
142
143 /**
144 * The stream: it replays what `since` missed, then follows. The listener is
145 * dropped when the request aborts and when the stream is cancelled, so a
146 * reload leaves nothing behind, and a colon every 25 seconds keeps a proxy
147 * from closing a quiet one.
148 *
149 * `endsAt` is when this connection is closed whatever happens: a stamp, so
150 * the caller can end it at the earlier of a few minutes and the moment the
151 * forge stops vouching for whoever opened it. Closing is all it does — the
152 * browser reconnects through the route that checked the role in the first
153 * place, and is turned away there if it is no longer an admin.
154 */
155 export function events(request: Request, endsAt = Date.now() + MAX_LIFE_MS): Response {
156 const asked = Number(
157 request.headers.get("last-event-id") ?? new URL(request.url).searchParams.get("since") ?? 0,
158 );
159 const from = Number.isFinite(asked) ? asked : 0;
160 const encoder = new TextEncoder();
161 let stop = (): void => {};
162 const stream = new ReadableStream<Uint8Array>({
163 start(controller) {
164 const write = (text: string): void => {
165 try {
166 controller.enqueue(encoder.encode(text));
167 // A connection that has stopped reading queues everything we say.
168 // Past a few hundred frames it is dropped instead of held: an
169 // EventSource reconnects on its own and replays what it missed.
170 if ((controller.desiredSize ?? 0) < -MAX_QUEUED) stop();
171 } catch {
172 stop();
173 }
174 };
175 // What this connection has already been given. The replay below reaches
176 // above `handedOut` when another process has just written, and the poll
177 // then hands those same rows to every listener; a connection that has
178 // seen an id does not see it twice.
179 let last = from;
180 const send = (event: LiveEvent): void => {
181 if (event.id <= last) return;
182 last = event.id;
183 write(`id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`);
184 };
185 const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS);
186 heartbeat.unref?.();
187 // Unref'd like the heartbeat: an end for the connection, never a reason
188 // for the process to stay up waiting to deliver it.
189 const life = setTimeout(() => stop(), Math.max(0, endsAt - Date.now()));
190 life.unref?.();
191 const unsubscribe = subscribe(send);
192 stop = () => {
193 clearTimeout(life);
194 clearInterval(heartbeat);
195 unsubscribe();
196 try {
197 controller.close();
198 } catch {}
199 };
200 request.signal.addEventListener("abort", stop);
201 write("retry: 2000\n\n");
202 // Nothing can be emitted between the subscribe above and this replay:
203 // both run in one turn, so a page hears every event exactly once.
204 for (const event of since(from)) send(event);
205 },
206 cancel() {
207 stop();
208 },
209 });
210 return new Response(stream, {
211 headers: {
212 "content-type": "text/event-stream",
213 "cache-control": "no-store",
214 "x-accel-buffering": "no",
215 },
216 });
217 }
src/orders.ts +56 −18
@@ -9,6 +9,11 @@
99 * refunds are rows in `payment`, movements of a gift card are rows in
1010 * `gift_card_entry`, each with an idempotency key, so a double submit or a
1111 * repeated webhook records one of them.
12 *
13 * Each of those writes tells the admin's stream what it did — `emit` from
14 * src/live.ts, once, after the transaction has committed and never inside it:
15 * an order that rolled back must not appear on anybody's screen, and a
16 * webhook arriving twice writes one row, so it announces one thing.
1217 */
1318
1419 import * as discounts from "./discounts.ts";
@@ -17,6 +22,7 @@ import { randomBytes } from "node:crypto";
1722 import { db, type Order, type OrderItem, type Payment } from "./db.ts";
1823 import { addStock, takeStock } from "./inventory.ts";
1924 import { dueCents, sumLines } from "./money.ts";
25 import { emit } from "./live.ts";
2026 import { quote, type CartLine, type Quote } from "./cart.ts";
2127 import { writeReceipt } from "./mail.ts";
2228 import type { Applied } from "./codes.ts";
@@ -149,8 +155,9 @@ export function place(input: {
149155 if (priced.discount_cents !== input.shown.discount_cents)
150156 return { ok: false, error: "That code stopped applying while you were checking out." };
151157
158 let placed: Order;
152159 try {
153 const order = db.transaction(() => {
160 placed = db.transaction(() => {
154161 let created: Order | null = null;
155162 for (let attempt = 0; attempt < 5 && created === null; attempt++) {
156163 try {
@@ -229,8 +236,6 @@ export function place(input: {
229236 note(created.id, "placed", `${itemRows.length} line${itemRows.length === 1 ? "" : "s"}, awaiting payment`);
230237 return created;
231238 })();
232 // A gift card that covered the lot leaves nothing for a gateway to take.
233 return { ok: true, order: dueCents(order) === 0 ? payWithGiftCard(order) : order, reused: false };
234239 } catch (error) {
235240 const message = String(error instanceof Error ? error.message : error);
236241 if (message.startsWith("OUT_OF_STOCK:"))
@@ -238,6 +243,13 @@ export function place(input: {
238243 if (message.startsWith("CODE_GONE:")) return { ok: false, error: message.slice("CODE_GONE:".length) };
239244 return { ok: false, error: "The order could not be placed. Nothing was charged." };
240245 }
246
247 // Committed: the order and the stock it took are facts now, so this is
248 // where the admin's screen hears about them.
249 emit("order.placed", { id: placed.id, number: placed.number, cents: placed.total_cents });
250 for (const item of itemRows) emit("stock.moved", { id: item.variant_id, sku: item.sku });
251 // A gift card that covered the lot leaves nothing for a gateway to take.
252 return { ok: true, order: dueCents(placed) === 0 ? payWithGiftCard(placed) : placed, reused: false };
241253 }
242254
243255 export type Charge = { gateway: string; ref: string; amount_cents: number };
@@ -269,6 +281,11 @@ export function shortfall(order: Order): Shortfall | null {
269281 *
270282 * Idempotent: the charge's key is the order's, so a webhook and a browser
271283 * returning at the same time write one charge and one event between them.
284 *
285 * The admin's screen hears `order.paid` from the one branch that actually
286 * marked the order paid, and only when the UPDATE moved the row. A short
287 * payment announces nothing: the ledger says awaiting payment, and a live
288 * page that said otherwise would be the screen lying about the money.
272289 */
273290 const alreadyShort = (orderId: number): boolean =>
274291 db
@@ -281,7 +298,7 @@ export function settleFromGateway(order: Order, charge: Charge): Order {
281298 const amount = Number.isFinite(charge.amount_cents) ? Math.trunc(charge.amount_cents) : 0;
282299 const ref = charge.ref.slice(0, 200);
283300
284 const settled = db.transaction(() => {
301 const took = db.transaction(() => {
285302 const fresh = byId(order.id)!;
286303 const inserted =
287304 amount > 0
@@ -293,16 +310,16 @@ export function settleFromGateway(order: Order, charge: Charge): Order {
293310 .run(fresh.id, amount, charge.gateway, ref, `charge:${fresh.number}`).changes
294311 : 0;
295312 // Already recorded: the other arrival settled this order, whichever way.
296 if (inserted === 0 && amount > 0) return byId(fresh.id)!;
313 if (inserted === 0 && amount > 0) return { paid: false, order: byId(fresh.id)! };
297314
298315 const due = dueCents(fresh);
299316 if (amount >= due) {
300 db.query(
317 const changed = db.query(
301318 `UPDATE "order" SET status = 'paid', paid_at = datetime('now'), payment_ref = ?2, gateway = ?3
302319 WHERE id = ?1 AND status = 'pending_payment'`,
303 ).run(fresh.id, ref, charge.gateway);
320 ).run(fresh.id, ref, charge.gateway).changes;
304321 note(fresh.id, "paid", `${charge.gateway} ${ref}`);
305 return byId(fresh.id)!;
322 return { paid: changed === 1, order: byId(fresh.id)! };
306323 }
307324 // Short. The order stays where it was; the gateway's handle is kept so
308325 // whoever looks at this can find the charge at the gateway.
@@ -313,16 +330,21 @@ export function settleFromGateway(order: Order, charge: Charge): Order {
313330 );
314331 // An amount of nothing wrote no row to be idempotent against, so the event
315332 // guards itself: a repeated delivery of it is the same fact, not a new one.
316 if (amount <= 0 && alreadyShort(fresh.id)) return byId(fresh.id)!;
333 if (amount <= 0 && alreadyShort(fresh.id)) return { paid: false, order: byId(fresh.id)! };
317334 note(
318335 fresh.id,
319336 "short-payment",
320337 `asked ${(due / 100).toFixed(2)} ${fresh.currency}, ${charge.gateway} paid ${(amount / 100).toFixed(2)} — ` +
321338 `${((due - amount) / 100).toFixed(2)} short${ref ? ` (${ref})` : ""}`,
322339 );
323 return byId(fresh.id)!;
340 return { paid: false, order: byId(fresh.id)! };
324341 })();
325 return settle(settled);
342 const settled = settle(took.order);
343 // Only the call that moved the order to paid says so: the browser coming
344 // back from Stripe and the webhook both end up here, and one charge is one
345 // event. The gateway's reference stays out of it.
346 if (took.paid) emit("order.paid", { id: settled.id, number: settled.number, cents: amount });
347 return settled;
326348 }
327349
328350 /**
@@ -331,7 +353,7 @@ export function settleFromGateway(order: Order, charge: Charge): Order {
331353 * already written.
332354 */
333355 export function payWithGiftCard(order: Order): Order {
334 const paid = db.transaction(() => {
356 const took = db.transaction(() => {
335357 const changed = db
336358 .query(
337359 `UPDATE "order" SET status = 'paid', paid_at = datetime('now'), gateway = 'gift_card'
@@ -339,9 +361,11 @@ export function payWithGiftCard(order: Order): Order {
339361 )
340362 .run(order.id).changes;
341363 if (changed === 1) note(order.id, "paid", `gift card ····${giftcards.cardSpentOn(order.id)?.last4 ?? "????"}`);
342 return byId(order.id)!;
364 return { charged: changed === 1, order: byId(order.id)! };
343365 })();
344 return settle(paid);
366 const paid = settle(took.order);
367 if (took.charged) emit("order.paid", { id: paid.id, number: paid.number, cents: paid.gift_card_cents });
368 return paid;
345369 }
346370
347371 /**
@@ -383,8 +407,9 @@ export function refund(input: {
383407 .get(input.idempotencyKey);
384408 if (already) return { ok: true, payment: already, order: byId(order.id)! };
385409
410 let done: RefundResult;
386411 try {
387 return db.transaction((): RefundResult => {
412 done = db.transaction((): RefundResult => {
388413 const fresh = byId(order.id)!;
389414 const left = refundableCents(fresh);
390415 if (left <= 0) return { ok: false, error: "This order has nothing left to refund." };
@@ -437,6 +462,13 @@ export function refund(input: {
437462 } catch {
438463 return { ok: false, error: "The refund could not be recorded." };
439464 }
465
466 if (done.ok) {
467 emit("order.refunded", { id: done.order.id, number: done.order.number, cents: input.amount_cents });
468 if (input.restock)
469 for (const item of itemsOf(done.order.id)) emit("stock.moved", { id: item.variant_id, sku: item.sku });
470 }
471 return done;
440472 }
441473
442474 /**
@@ -444,9 +476,9 @@ export function refund(input: {
444476 * code gets its use back and the gift card gets its balance back.
445477 */
446478 export function cancel(order: Order, reason: string): Order {
447 return db.transaction(() => {
479 const ended = db.transaction(() => {
448480 const fresh = byId(order.id)!;
449 if (fresh.status !== "pending_payment") return fresh;
481 if (fresh.status !== "pending_payment") return { cancelled: false, order: fresh, items: [] as OrderItem[] };
450482 db.query('UPDATE "order" SET status = \'cancelled\' WHERE id = ?1').run(fresh.id);
451483 for (const item of itemsOf(fresh.id))
452484 addStock(item.variant_id, item.quantity, `cancelled ${fresh.number}`, fresh.id);
@@ -466,6 +498,12 @@ export function cancel(order: Order, reason: string): Order {
466498 );
467499 }
468500 note(fresh.id, "cancelled", reason);
469 return byId(fresh.id)!;
501 return { cancelled: true, order: byId(fresh.id)!, items: itemsOf(fresh.id) };
470502 })();
503
504 if (ended.cancelled) {
505 emit("order.cancelled", { id: ended.order.id, number: ended.order.number });
506 for (const item of ended.items) emit("stock.moved", { id: item.variant_id, sku: item.sku });
507 }
508 return ended.order;
471509 }
src/pages/admin.ts +45 −10
@@ -2,6 +2,12 @@
22 * /admin: the pages behind the session. Products, inventory, orders, refunds
33 * and the outbox. Everything an admin can do is a form with a CSRF token and
44 * a POST; nothing changes on a GET.
5 *
6 * The overview, the orders list and the inventory tables are wrapped in
7 * `data-live` regions: static/live.js refetches this same page when the bus
8 * says an order or a stock level moved, and swaps those regions by id. The
9 * markup is the markup either way — with the script off, the page is what it
10 * always was, one reload behind.
511 */
612
713 import { csrfField, escape, variantRowStyle } from "../html.ts";
@@ -18,6 +24,17 @@ import type { Email } from "../mail.ts";
1824 import type { Session } from "../session.ts";
1925 import type { Shortfall } from "../orders.ts";
2026
27 /** What the ledger emits about an order, and about a stock movement. */
28 const ORDER_EVENTS = "order.placed order.paid order.refunded order.cancelled";
29 const STOCK_EVENTS = "stock.moved";
30
31 /**
32 * A region the stream keeps current. The id is what static/live.js swaps by,
33 * so it is stable across renders; the types are the events worth a refetch.
34 */
35 const live = (id: string, types: string, body: string): string =>
36 `<div id="${escape(id)}" data-live="${escape(types)}">${body}</div>`;
37
2138 export const nav = (current: string): string =>
2239 `<nav>${[
2340 ["/admin", "Overview", "overview"],
@@ -59,7 +76,10 @@ export const overview = (
5976 ): string => `
6077 ${nav("overview")}
6178 <h1>Overview</h1>
62 <div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr))">
79 ${live(
80 "admin-totals",
81 `${ORDER_EVENTS} ${STOCK_EVENTS}`,
82 `<div class="grid" style="grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr))">
6383 ${[
6484 ["Orders", String(numbers.orders)],
6585 ["Paid", String(numbers.paid)],
@@ -74,14 +94,18 @@ ${nav("overview")}
7494 <div style="font-size:1.4rem; font-weight:600" class="price">${escape(value)}</div></div>`,
7595 )
7696 .join("\n")}
77 </div>
97 </div>`,
98 )}
7899 <h2>Latest orders</h2>
79 ${orderRows(latest)}
100 ${live("latest-orders", ORDER_EVENTS, orderRows(latest))}
80101 `;
81102
82103 export function orderRows(orders: Order[]): string {
83104 if (orders.length === 0) return '<p class="muted">No orders yet.</p>';
84 return `<table>
105 // Fixed columns: a row arriving at the top cannot move the ones below it
106 // sideways, whatever length the name in it is.
107 return `<table class="steady">
108 <colgroup><col style="width:20%"><col style="width:22%"><col style="width:28%"><col style="width:15%"><col style="width:15%"></colgroup>
85109 <thead><tr><th>Order</th><th>Placed</th><th>Customer</th><th>Status</th><th class="num">Total</th></tr></thead>
86110 <tbody>${orders
87111 .map(
@@ -103,7 +127,7 @@ ${nav("orders")}
103127 <input name="q" value="${escape(query)}" placeholder="Number, name or email" maxlength="80" style="max-width:22rem">
104128 <button class="btn ghost">Search</button>
105129 </form>
106 ${orderRows(list)}
130 ${live("order-list", ORDER_EVENTS, orderRows(list))}
107131 `;
108132
109133 export function order(
@@ -294,8 +318,13 @@ export function inventory(
294318 ${nav("inventory")}
295319 <h1>Inventory</h1>
296320 ${message ? `<p class="notice${message.bad ? " bad" : ""}">${escape(message.text)}</p>` : ""}
297 <p class="lede">Stock moves by writing a reason. Every line below is a row in the ledger.</p>
298 <table>
321 <p class="lede">Stock moves by writing a reason. Every line below is a row in the ledger, and both tables follow the
322 ledger as it is written — from here, from a checkout, or from a refund putting stock back.</p>
323 ${live(
324 "stock-on-hand",
325 STOCK_EVENTS,
326 `<table class="steady">
327 <colgroup><col style="width:22%"><col style="width:30%"><col style="width:13%"><col style="width:35%"></colgroup>
299328 <thead><tr><th>SKU</th><th>Item</th><th class="num">On hand</th><th>Move</th></tr></thead>
300329 <tbody>${rows
301330 .map(
@@ -316,9 +345,14 @@ ${message ? `<p class="notice${message.bad ? " bad" : ""}">${escape(message.text
316345 </tr>`,
317346 )
318347 .join("\n")}</tbody>
319 </table>
348 </table>`,
349 )}
320350 <h2>Ledger</h2>
321 <table>
351 ${live(
352 "stock-ledger",
353 STOCK_EVENTS,
354 `<table class="steady">
355 <colgroup><col style="width:20%"><col style="width:20%"><col style="width:12%"><col style="width:48%"></colgroup>
322356 <thead><tr><th>When</th><th>SKU</th><th class="num">Change</th><th>Reason</th></tr></thead>
323357 <tbody>${ledger
324358 .map(
@@ -326,7 +360,8 @@ ${message ? `<p class="notice${message.bad ? " bad" : ""}">${escape(message.text
326360 <td class="num">${entry.delta > 0 ? "+" : ""}${entry.delta}</td><td>${escape(entry.reason)}</td></tr>`,
327361 )
328362 .join("\n")}</tbody>
329 </table>
363 </table>`,
364 )}
330365 `;
331366 }
332367
src/routes.ts +1
@@ -50,6 +50,7 @@ export const routes: Route[] = [
5050 // Everything under /admin is for an admin the forge signed; the guard in
5151 // src/admin.ts is on each handler, not on this table.
5252 { method: "GET", pattern: "/admin", handler: admin.dashboard },
53 { method: "GET", pattern: "/admin/events", handler: admin.events },
5354 { method: "GET", pattern: "/admin/products", handler: admin.productList },
5455 { method: "POST", pattern: "/admin/products", handler: admin.productCreate },
5556 { method: "GET", pattern: "/admin/products/new", handler: admin.productNew },
src/server.test.ts +208
@@ -13,6 +13,7 @@ const { stamp } = await import("./who.ts");
1313 const { server } = await import("./server.ts");
1414 const { db } = await import("./db.ts");
1515 const limit = await import("./ratelimit.ts");
16 const live = await import("./live.ts");
1617 const giftcards = await import("./giftcards.ts");
1718 const discounts = await import("./discounts.ts");
1819 const tax = await import("./tax.ts");
@@ -1378,6 +1379,213 @@ test("/admin/changes is an admin's page and nobody else's", async () => {
13781379 expect(await refused.text()).not.toContain("AUD-KETTLE-1");
13791380 });
13801381
1382 // --- live: one bus, one stream ----------------------------------------------
1383
1384 /**
1385 * Read an open event stream until its text says `wanted`. The pending read is
1386 * held across the waits, or the chunk that loses a race is lost with it.
1387 */
1388 async function until(response: Response, wanted: string, ms = 5000): Promise<string> {
1389 const reader = response.body!.getReader();
1390 const decoder = new TextDecoder();
1391 let pending: Promise<ReadableStreamReadResult<Uint8Array>> | null = null;
1392 let text = "";
1393 const deadline = Date.now() + ms;
1394 while (!text.includes(wanted) && Date.now() < deadline) {
1395 pending ??= reader.read();
1396 const step = await Promise.race([pending, Bun.sleep(100).then(() => "waited" as const)]);
1397 if (step === "waited") continue;
1398 pending = null;
1399 if (step.done) break;
1400 text += decoder.decode(step.value, { stream: true });
1401 }
1402 await reader.cancel();
1403 return text;
1404 }
1405
1406 const ADMIN_HEADERS = (): Record<string, string> =>
1407 identityHeaders({ id: "u_live", name: "ada", role: "admin" });
1408
1409 /**
1410 * A stream that starts here and not at the top of the bus. Every test in
1411 * this file shares one process and one bus, so a connection with nothing to
1412 * go on replays what the tests before it did: this marks the tail first and
1413 * asks for what comes after it.
1414 */
1415 const watch = async (stop: AbortController): Promise<Response> => {
1416 const here = live.emit("test.mark", {});
1417 return fetch(`${base}/admin/events?since=${here.id}`, { headers: ADMIN_HEADERS(), signal: stop.signal });
1418 };
1419
1420 test("an admin watching the stream hears an order a stranger places", async () => {
1421 const stop = new AbortController();
1422 const stream = await watch(stop);
1423 expect(stream.status).toBe(200);
1424 expect(stream.headers.get("content-type")).toBe("text/event-stream");
1425
1426 const shopper = new Shopper();
1427 await addToCart(shopper, "COF-GUJI-250", 1);
1428 const placed = await submitCheckout(shopper, { email: "[email protected]" });
1429 expect(placed.status).toBe(303);
1430 const { number } = orderAt(placed.headers.get("location")!);
1431
1432 // The stock the order took is announced after the order itself, so waiting
1433 // for that waits for both.
1434 const heard = await until(stream, "stock.moved");
1435 expect(heard).toContain("retry: 2000");
1436 expect(heard).toContain("order.placed");
1437 expect(heard).toContain("stock.moved");
1438 expect(heard).toContain(number);
1439
1440 // Nothing private travels: an id, the order's number, a figure in cents,
1441 // a SKU, and not one word of the person who placed it.
1442 const events = [...heard.matchAll(/^data: (.*)$/gm)].map((line) => JSON.parse(line[1]!));
1443 const orderPlaced = events.find((event) => event.type === "order.placed")!;
1444 expect(Object.keys(orderPlaced.data).sort()).toEqual(["cents", "id", "number"]);
1445 expect(orderPlaced.data.number).toBe(number);
1446 expect(orderPlaced.data.cents).toBe(orderNamed(number).total_cents);
1447 expect(heard).not.toContain("[email protected]");
1448 expect(heard).not.toContain("1 Test Street");
1449 expect(heard).not.toContain("mock_");
1450 stop.abort();
1451 });
1452
1453 test("paying and refunding an order reach the stream, once each", async () => {
1454 const shopper = new Shopper();
1455 await addToCart(shopper, "COF-HOUSE-250", 1);
1456 const placed = await submitCheckout(shopper, { email: "[email protected]" });
1457 const { number, token } = orderAt(placed.headers.get("location")!);
1458
1459 const stop = new AbortController();
1460 const stream = await watch(stop);
1461 expect((await payMock(shopper, number, token)).status).toBe(303);
1462
1463 const admin = asAdmin();
1464 const page = await admin.page(`/admin/orders/${number}`);
1465 const refunded = await admin.post(
1466 `/admin/orders/${number}/refund`,
1467 { amount: "1.00", reason: "a test", restock: "1" },
1468 Shopper.tokenIn(page),
1469 );
1470 expect(refunded.status).toBe(303);
1471
1472 const heard = await until(stream, "order.refunded");
1473 const events = [...heard.matchAll(/^data: (.*)$/gm)].map((line) => JSON.parse(line[1]!));
1474 const mine = events.filter((event) => event.data?.number === number);
1475 expect(mine.filter((event) => event.type === "order.paid").length).toBe(1);
1476 expect(mine.filter((event) => event.type === "order.refunded")).toEqual([
1477 { type: "order.refunded", data: { id: orderNamed(number).id, number, cents: 100 } },
1478 ]);
1479 stop.abort();
1480 });
1481
1482 test("the stream is closed to anyone who is not an admin", async () => {
1483 for (const person of [undefined, { id: "u_2", name: "bo", role: "user" as const }]) {
1484 const response = await fetch(base + "/admin/events", { headers: identityHeaders(person) });
1485 expect(response.status).toBe(403);
1486 expect(response.headers.get("content-type")).not.toContain("event-stream");
1487 expect(await response.text()).toBe("forbidden");
1488 }
1489 });
1490
1491 /**
1492 * Every other door here proves who is asking on every request. A stream is
1493 * one request that lasts, so it is given an end: what it must not be is a way
1494 * for an identity the forge has stopped vouching for to keep hearing the
1495 * shop's orders, in cents, for as long as it holds the socket open.
1496 */
1497 test(
1498 "a stream does not outlive the identity that opened it",
1499 async () => {
1500 const brief = { id: "u_brief", name: "ada", role: "admin" as const, expiresMs: Date.now() + 1500 };
1501 const mark = live.emit("test.mark", {});
1502 const stream = await fetch(`${base}/admin/events?since=${mark.id}`, { headers: identityHeaders(brief) });
1503 expect(stream.status).toBe(200);
1504
1505 // Read it to its end. It has one: reaching this line is the assertion.
1506 const reader = stream.body!.getReader();
1507 const decoder = new TextDecoder();
1508 let text = "";
1509 const drained = (async () => {
1510 for (;;) {
1511 const step = await reader.read();
1512 if (step.done) return;
1513 text += decoder.decode(step.value, { stream: true });
1514 }
1515 })();
1516 const ended = await Promise.race([drained.then(() => true), Bun.sleep(10_000).then(() => false)]);
1517 expect(ended).toBe(true);
1518 expect(Date.now()).toBeGreaterThanOrEqual(brief.expiresMs);
1519 expect(text).toContain("retry: 2000");
1520
1521 // Past the expiry, and it hears nothing of this.
1522 live.emit("order.paid", { id: 1, number: "BZ-AFTER-EXPIRY", cents: 1234 });
1523 await Bun.sleep(1300); // Longer than a pass of the poll.
1524 expect(text).not.toContain("BZ-AFTER-EXPIRY");
1525
1526 // The door it would come back through is the one the pages use, and it
1527 // is shut now: the reconnect is where an expired admin is turned away.
1528 const again = await fetch(base + "/admin/events", { headers: identityHeaders(brief) });
1529 expect(again.status).toBe(403);
1530 expect((await fetch(base + "/admin", { headers: identityHeaders(brief) })).status).toBe(403);
1531 },
1532 15_000,
1533 );
1534
1535 test("the floor allows this origin's own script and no inline one", async () => {
1536 const policy = (await fetch(base + "/")).headers.get("content-security-policy")!;
1537 // 'self' and nothing more on script-src: no host, no nonce, no scheme.
1538 expect(policy).toContain("script-src 'self';");
1539 expect(policy).not.toContain("unsafe-eval");
1540 // Inline styles are allowed and inline scripts are not: the only
1541 // 'unsafe-inline' in the policy is the one on style-src.
1542 expect(policy.match(/'unsafe-inline'/g)!.length).toBe(1);
1543 expect(policy).toContain("style-src 'self' 'unsafe-inline'");
1544 });
1545
1546 test("the shop loads no script at all, and the admin's live pages load one file", async () => {
1547 for (const path of ["/", "/c/coffee", "/p/ethiopia-guji", "/cart", "/checkout", "/gift-cards", "/orders"]) {
1548 const shopper = new Shopper();
1549 await addToCart(shopper, "COF-GUJI-250", 1); // so /checkout is a page and not a redirect
1550 expect(await shopper.page(path)).not.toContain("<script");
1551 }
1552
1553 const admin = asAdmin();
1554 const dashboard = await admin.page("/admin");
1555 const src = /<script type="module" src="([^"]+)"><\/script>/.exec(dashboard)![1]!;
1556 expect(src).toMatch(/^\/static\/live\.[0-9a-f]{8}\.js$/);
1557 expect(dashboard.split("<script").length).toBe(2);
1558
1559 const script = await fetch(base + src);
1560 expect(script.status).toBe(200);
1561 expect(script.headers.get("content-type")).toContain("javascript");
1562 expect(script.headers.get("cache-control")).toContain("immutable");
1563 expect(await script.text()).toContain("data-live");
1564
1565 // The name is the contents: a different name is not this file.
1566 expect((await fetch(base + "/static/live.00000000.js")).status).toBe(404);
1567 expect((await fetch(base + "/static/live.js")).status).toBe(404);
1568
1569 // A page with no live region on it stays a page with no script on it.
1570 expect(await admin.page("/admin/tax")).not.toContain("<script");
1571 });
1572
1573 test("the admin's live regions are marked with stable ids", async () => {
1574 const admin = asAdmin();
1575 const regions = (html: string): Record<string, string> =>
1576 Object.fromEntries([...html.matchAll(/id="([^"]+)" data-live="([^"]+)"/g)].map((m) => [m[1]!, m[2]!]));
1577
1578 const dashboard = regions(await admin.page("/admin"));
1579 expect(Object.keys(dashboard)).toEqual(["admin-totals", "latest-orders"]);
1580 expect(dashboard["latest-orders"]).toContain("order.placed");
1581 expect(regions(await admin.page("/admin/orders"))["order-list"]).toContain("order.paid");
1582 const inventory = regions(await admin.page("/admin/inventory"));
1583 expect(inventory["stock-on-hand"]).toBe("stock.moved");
1584 expect(inventory["stock-ledger"]).toBe("stock.moved");
1585 // Fixed columns, so a row arriving cannot move the rows around it.
1586 expect(await admin.page("/admin/inventory")).toContain('<table class="steady">');
1587 });
1588
13811589 test("this file's rows are this file's own", async () => {
13821590 // The isolation every test file depends on: the database the app modules
13831591 // are querying is the one this file opened, not one another file chose.
src/server.ts +22 −9
@@ -11,6 +11,7 @@
1111 */
1212
1313 import { dataDir, db, forgetPasswordSettings } from "./db.ts";
14 import { fileOf } from "./asset.ts";
1415 import { decode as decodeCart, COOKIE as CART_COOKIE } from "./cart.ts";
1516 import { decode as decodeCodes, COOKIE as CODES_COOKIE } from "./codes.ts";
1617 import { CSS, CSS_PATH } from "./style.ts";
@@ -46,12 +47,18 @@ const env = process.env.FORGE_APP_ENV ?? "local";
4647 // this origin (inline styles allowed, inline scripts not), nothing frames us,
4748 // browsers never guess a content type, and referrers stay on this site.
4849 //
50 // `script-src 'self'` is the one loosening this app has made, and it is a
51 // narrow one: the admin's live regions need static/live.js, which is a file
52 // on this origin under a name made from its contents. There is no
53 // 'unsafe-inline' and no 'unsafe-eval' here and no page carries an inline
54 // script, so a value that escaped an escape still has nothing to run.
55 //
4956 // Forms may only submit to this origin. The one exception is the response that
5057 // hands a shopper to a hosted payment page: it names that origin with HAND_OFF
5158 // and gets it added to `form-action` for itself alone, because Chromium checks
5259 // the redirect a form navigation follows against this policy.
5360 const csp = (formAction: string): string =>
54 `default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action ${formAction}`;
61 `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action ${formAction}`;
5562
5663 // A browser on another site can make a person's browser POST here; it cannot
5764 // make that request look same-origin. Browsers say where a request came from
@@ -98,6 +105,12 @@ function finish(response: Response, cookies: string[]): Response {
98105 export const server = Bun.serve({
99106 port: Number(process.env.PORT ?? 8080),
100107 hostname: process.env.HOST ?? "127.0.0.1",
108 // This is the whole server's idle timeout, not the stream's: every
109 // connection now gets a minute of silence rather than Bun's ten seconds.
110 // The stream is why — it heartbeats every 25 seconds, which the default
111 // would cut off — and the cost is that a shopper's idle socket is held six
112 // times as long, which the forge's proxy in front of us makes cheap.
113 idleTimeout: 60,
101114 async fetch(request, self) {
102115 const url = new URL(request.url);
103116 const method = request.method === "HEAD" ? "GET" : request.method;
@@ -113,14 +126,14 @@ export const server = Bun.serve({
113126 }),
114127 [],
115128 );
116 if (url.pathname.startsWith("/static/") && !url.pathname.includes("..")) {
117 const file = Bun.file(`${import.meta.dir}/../${url.pathname.slice(1)}`);
118 if (await file.exists())
119 return finish(
120 new Response(file, { headers: { "cache-control": "public, max-age=31536000, immutable" } }),
121 [],
122 );
123 }
129 // The files that are files — static/live.js — the same way: the hash in
130 // the path has to be the hash of the bytes, or it is not found here.
131 const file = fileOf(url.pathname);
132 if (file !== null)
133 return finish(
134 new Response(Bun.file(file), { headers: { "cache-control": "public, max-age=31536000, immutable" } }),
135 [],
136 );
124137
125138 if (method === "POST" && !sameOrigin(request)) return finish(text("forbidden", 403), []);
126139
src/stripe.test.ts +2 −1
@@ -397,7 +397,8 @@ test("a Checkout Session is built from the order's lines and asks for what is du
397397 // so does not leave. Nothing else in the floor moves.
398398 const policy = response.headers.get("content-security-policy")!;
399399 expect(policy).toContain(`form-action 'self' ${fakeBase()}`);
400 expect(policy).toContain("script-src 'none'");
400 // Scripts still come from this origin only, and inline ones are still out.
401 expect(policy).toContain("script-src 'self';");
401402 expect(policy).toContain("frame-ancestors 'none'");
402403 expect(response.headers.get("x-hand-off-to")).toBeNull();
403404
src/style.ts +5
@@ -66,6 +66,11 @@ table { width: 100%; border-collapse: collapse; font-size: .95rem; }
6666 th, td { text-align: left; padding: .55rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; }
6767 th { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--dim); }
6868 td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
69 /* A table a row can arrive in while it is being read: the columns are laid
70 out from the colgroup, not from the contents, so a longer value in a new
71 row cannot move the rows above it — and it fits a phone at any width. */
72 table.steady { table-layout: fixed; }
73 table.steady th, table.steady td { overflow-wrap: anywhere; }
6974 .totals { margin-left: auto; max-width: 20rem; }
7075 .totals .row { display: flex; justify-content: space-between; padding: .3rem 0; }
7176 .totals .row.grand { border-top: 1px solid var(--line); margin-top: .3rem; padding-top: .6rem; font-weight: 700; font-size: 1.1rem; }
src/units.test.ts +25
@@ -21,6 +21,7 @@ const changes = await import("./changes.ts");
2121 const desk = await import("./desk.ts");
2222 const discounts = await import("./discounts.ts");
2323 const giftcards = await import("./giftcards.ts");
24 const live = await import("./live.ts");
2425 const tax = await import("./tax.ts");
2526 const orders = await import("./orders.ts");
2627 const dbModule = await import("./db.ts");
@@ -703,6 +704,30 @@ test("a gateway's amount is compared to what the order owes before anything is c
703704 expect(orders.eventsOf(twice.id).filter((event) => event.kind === "paid").length).toBe(1);
704705 });
705706
707 test("the admin's screen is told about a payment only when the order becomes paid", () => {
708 const paidEvents = (): unknown[] => heard.filter((event) => event.type === "order.paid");
709 const heard: { type: string; data: any }[] = [];
710 const unsubscribe = live.subscribe((event) => heard.push({ type: event.type, data: event.data }));
711 try {
712 // Short. The ledger says awaiting payment, so a live page that said paid
713 // would be the admin's screen lying about the money.
714 const short = pendingOrder("BZ-SHORT-LIVE", 5000);
715 orders.settleFromGateway(short, { gateway: "mock", ref: "ref_short_live", amount_cents: 4500 });
716 expect(paidEvents()).toEqual([]);
717
718 // Covered: one event, from the branch that moved the row to paid.
719 const full = pendingOrder("BZ-FULL-LIVE", 5000);
720 orders.settleFromGateway(full, { gateway: "mock", ref: "ref_full_live", amount_cents: 5000 });
721 expect(paidEvents()).toEqual([{ type: "order.paid", data: { id: full.id, number: "BZ-FULL-LIVE", cents: 5000 } }]);
722
723 // The webhook arriving behind the browser is the same charge, not another.
724 orders.settleFromGateway(full, { gateway: "mock", ref: "ref_full_again", amount_cents: 5000 });
725 expect(paidEvents().length).toBe(1);
726 } finally {
727 unsubscribe();
728 }
729 });
730
706731 test("a test file's database is its own, from the factory", () => {
707732 // bun test shares one process and one module registry across files, so this
708733 // is the assertion that keeps every other one honest: the database the app
src/who.ts +15
@@ -46,6 +46,21 @@ export function whoIs(headers: Headers, now = Date.now()): Who {
4646 return { role, id, name };
4747 }
4848
49 /**
50 * When the forge stops vouching for whoever is asking, as a stamp, or 0 when
51 * there is nobody verified to stop vouching for. Every ordinary handler can
52 * ignore this — it runs once per request, and `whoIs` has already refused an
53 * expiry that has passed. It is here for the one thing that outlives a
54 * request, the event stream, which has to be told when to end.
55 *
56 * The field is read only after `whoIs` has proved the signature over the
57 * whole identity string, so it is the forge's number and not the caller's.
58 */
59 export function expiresAt(headers: Headers, now = Date.now()): number {
60 if (whoIs(headers, now).role === "anon") return 0;
61 return Number(headers.get(IDENTITY_HEADER)!.split("|")[3]);
62 }
63
4964 /** False when no FORGE_APP_IDENTITY_KEY is set: then nobody can be an admin here. */
5065 export const identityConfigured = (): boolean => Boolean(process.env.FORGE_APP_IDENTITY_KEY);
5166
static/live.js added +79
@@ -0,0 +1,79 @@
1 // The admin pages stay current without a reload. /admin/events is a stream
2 // of what changed in the ledger; each event becomes `live` and `live:<type>`
3 // on `document`, and any element with data-live="<types>" is refetched from
4 // the page it is already on and swapped by id — server-rendered, so nothing
5 // is ever rendered twice, in two places, in two ways.
6 //
7 // One refetch is in flight at a time; another event while one is running
8 // coalesces into a single run after it. Without this file the page is the
9 // page it is today, one reload behind.
10
11 let since = "";
12 let tries = 0;
13 let source;
14
15 function connect() {
16 source = new EventSource(since ? `/admin/events?since=${encodeURIComponent(since)}` : "/admin/events");
17 source.onopen = () => {
18 if (tries > 0) refresh(); // Reconnected: whatever was missed, catch up.
19 tries = 0;
20 };
21 source.onmessage = (message) => {
22 since = message.lastEventId;
23 let event;
24 try {
25 event = JSON.parse(message.data);
26 } catch {
27 return;
28 }
29 document.dispatchEvent(new CustomEvent("live", { detail: event }));
30 document.dispatchEvent(new CustomEvent(`live:${event.type}`, { detail: event.data }));
31 };
32 // The browser retries a dropped connection itself; a refused one — no
33 // longer an admin, say — it gives up on, and then we try again, later each
34 // time, up to half a minute.
35 source.onerror = () => {
36 if (source.readyState !== EventSource.CLOSED) return;
37 source.close();
38 setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++));
39 };
40 }
41
42 let refreshing = false;
43 let again = false;
44
45 async function refresh(types) {
46 const regions = [...document.querySelectorAll("[data-live]")].filter(
47 (element) => types === undefined || element.dataset.live.split(" ").some((type) => types.includes(type)),
48 );
49 if (regions.length === 0) return;
50 if (refreshing) {
51 again = true;
52 return;
53 }
54 refreshing = true;
55 try {
56 const response = await fetch(location.href, { headers: { accept: "text/html" } });
57 if (!response.ok) return;
58 const fresh = new DOMParser().parseFromString(await response.text(), "text/html");
59 for (const element of regions) {
60 // A region someone is typing in keeps what they typed: the forms in
61 // these tables matter more than a fresh number, and the next event
62 // swaps the region once the caret has moved on.
63 if (element.contains(document.activeElement)) continue;
64 const next = element.id && fresh.getElementById(element.id);
65 if (next) element.replaceWith(next);
66 }
67 } catch {
68 // Offline, or the page went away: the next event tries again.
69 } finally {
70 refreshing = false;
71 if (again) {
72 again = false;
73 refresh();
74 }
75 }
76 }
77
78 document.addEventListener("live", (event) => refresh([event.detail.type]));
79 connect();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs