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

Build bazaar #5

Merged opened this in answer to

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

Merged

ledger/bazaar:ledger/1-build-bazaar → main 8 commits fork 1 thread · 1 unresolved

  • Merged. ledger/1-build-bazaar was merged into main as 8874981.

Review

Sign in to review this change.
18 files changed +660 −69
README.md +58 −8
@@ -5,8 +5,10 @@ variants and stock, a cart in a signed cookie, discount codes and gift cards,
55 tax by where the parcel goes, checkout with an address, a payment step,
66 receipts, and an `/admin` for products, inventory, codes, cards, tax and
77 orders with refunds. Every price is an integer number of cents, every form
8 carries a CSRF token, and checkout, payment and typing a code are rate
9 limited.
8 carries a CSRF token, and checkout, payment, typing a code and finding an
9 order are rate limited. A category or a search longer than 24 things is
10 paged, so a listing is never a page without an end and never hides what is
11 past the end of it.
1012
1113 This is an app: the forge runs it. Production follows the default branch;
1214 every open pull gets a preview at its own address with its own copy of the
@@ -35,7 +37,8 @@ library are all of it. There is no client-side JavaScript.
3537 Decline buttons, that moves no money.
3638 - **Never real**: email. There is no mail server. A receipt is composed and
3739 stored, shown as an email-style page at `/orders/<number>/receipt` and
38 listed in the admin outbox at `/admin/mail`.
40 listed in the admin outbox at `/admin/mail`. Because no receipt is ever
41 delivered, `/orders` is how a shopper gets back to one — see below.
3942 - No goods ship. It is a demonstration store, and every page says so.
4043
4144 ## Codes, cards and tax
@@ -75,6 +78,51 @@ the goods, plus gift cards being bought — and only then is a card's balance
7578 taken off to leave what the gateway is asked for. `bun test` reconciles a
7679 cart with all three against the order it becomes.
7780
81 ## Finding an order again
82
83 An order is private, so it opens with its own access token and nothing else —
84 which leaves the shopper who closed the tab with no way back. `/orders` is
85 that way back, and it has two halves:
86
87 - **From this browser.** Placing an order appends its number to a signed
88 `mine` cookie, at most ten deep. The cookie holds numbers and nothing else;
89 the link is built from the access token read out of the row, so editing the
90 cookie changes which number is being claimed and never what opens. A shared
91 computer is not a shopper's, so **Forget these orders** drops the cookie.
92 - **The form.** An order number and the email it was placed with. Both have to
93 be right, the email is compared as a keyed digest so no letter of it times
94 differently — and a number that matches nothing is digested against a fixed
95 string, so a miss does the same work as a match — and a pair that does not
96 match is refused in the same words whether or not the number exists. This
97 form is not a way to learn which order numbers are real. It is rate limited
98 like the other guessable things (see below), and a lookup that succeeds
99 leaves a `looked-up` row in the order's events with the address it came
100 from.
101
102 ### What a rate limit is keyed on
103
104 A limit only limits if the caller cannot choose which bucket it spends, so
105 `src/peer.ts` decides where a request came from and a bucket is keyed on that
106 and nothing else: the address the socket arrived on. `X-Forwarded-For` is not
107 read. It would be the useful thing — it names the shopper behind a proxy — but
108 only if a proxy we trust appends to it, and nothing this app can see from the
109 inside proves that one does. A client can send that header itself, and a hint
110 nobody vouches for is the caller's own word; keyed on the leftmost entry, as
111 this app used to be, every bucket here was ten tries per invented header.
112
113 The consequence is deliberate and worth knowing: behind the forge's proxy every
114 shopper arrives on the same address, so these are the **shop's** limits rather
115 than each shopper's, and the capacities in `src/ratelimit.ts` are set for a
116 shop — 30 checkouts a minute, 30 codes every two minutes, 30 order lookups
117 every five. Ordinary trade never meets them; guessing at a gift card code or
118 an order number runs out of tries long before it runs out of guesses. If the
119 forge ever says it appends a hop of its own, the rightmost entry becomes worth
120 reading and `src/peer.ts` is the one file that changes.
121
122 The address is checked for the shape of one before it is used, which is also
123 why the `looked-up` note on an order can only ever hold an address and not a
124 sentence somebody chose for an auditor to read.
125
78126 ## Payment
79127
80128 | Environment | What happens |
@@ -162,10 +210,11 @@ for an admin.
162210 | `src/routes.ts` | The table of paths |
163211 | `src/db.ts` | The schema, opened once |
164212 | `src/catalog.ts` `src/inventory.ts` `src/orders.ts` | Products, the stock ledger, orders and refunds |
165 | `src/cart.ts` `src/codes.ts` `src/money.ts` `src/sign.ts` | The signed cart, the codes cookie, integer cents, HMACs |
213 | `src/cart.ts` `src/codes.ts` `src/mine.ts` `src/money.ts` `src/sign.ts` | The signed cart, the codes cookie, the orders this browser placed, integer cents, HMACs |
166214 | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is |
167215 | `src/who.ts` `src/session.ts` | Who the forge says is asking; the session that carries the CSRF token |
168 | `src/ratelimit.ts` `src/gateway.ts` `src/mail.ts` | Token buckets, Stripe or the mock, the outbox |
216 | `src/peer.ts` `src/ratelimit.ts` | Where a request came from, as far as it can be proved; the buckets keyed on it |
217 | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox |
169218 | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` |
170219 | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents |
171220 | `src/units.test.ts` `src/server.test.ts` `src/stripe.test.ts` | The rules on their own, the shop over HTTP, the Stripe path against a fake Stripe |
@@ -175,6 +224,7 @@ for an admin.
175224 The same shop over the same data. The exact command is in `FORGE_APP_SSH`
176225 when the app runs; the footer of every page prints it verbatim, and says the
177226 terminal is off when it is unset. `1/2/3` switch between stock, orders and the
178 outbox, `j`/`k` move, `s` moves stock with a reason, `r` refunds the rest of an
179 order and puts it back on the shelf, `q` leaves. The first line says who the
180 forge named at the keyboard; `s` and `r` are an admin's.
227 outbox, `j`/`k` move, `s` moves stock with a reason, `/` finds an order by
228 number, name or email — the terminal's half of `/orders` — `r` refunds the rest
229 of an order and puts it back on the shelf, `q` leaves. The first line says who
230 the forge named at the keyboard; `s` and `r` are an admin's.
src/catalog.ts +44 −15
@@ -36,38 +36,67 @@ export const productById = (id: number): ProductWithPrice | null =>
3636 )
3737 .get(id);
3838
39 export const products = (options: { category?: string; limit?: number; includeInactive?: boolean } = {}): ProductWithPrice[] => {
39 type Listing = { category?: string; limit?: number; offset?: number; includeInactive?: boolean };
40
41 const listingWhere = (options: Listing): string => {
4042 const where = ["1 = 1"];
4143 if (!options.includeInactive) where.push("p.active = 1");
4244 if (options.category) where.push("c.slug = $category");
43 return db
45 return where.join(" AND ");
46 };
47
48 export const products = (options: Listing = {}): ProductWithPrice[] =>
49 db
4450 .query<ProductWithPrice, any>(
4551 `SELECT ${PRODUCT_COLUMNS} FROM product p JOIN category c ON c.id = p.category_id
46 WHERE ${where.join(" AND ")} ORDER BY p.title LIMIT $limit`,
52 WHERE ${listingWhere(options)} ORDER BY p.title LIMIT $limit OFFSET $offset`,
4753 )
48 .all({ ...(options.category ? { $category: options.category } : {}), $limit: options.limit ?? 60 });
49 };
54 .all({
55 ...(options.category ? { $category: options.category } : {}),
56 $limit: options.limit ?? 60,
57 $offset: options.offset ?? 0,
58 });
59
60 /** How many there are in total, so a page can say whether there is another. */
61 export const countProducts = (options: Listing = {}): number =>
62 db
63 .query<{ n: number }, any>(
64 `SELECT COUNT(*) AS n FROM product p JOIN category c ON c.id = p.category_id WHERE ${listingWhere(options)}`,
65 )
66 .get({ ...(options.category ? { $category: options.category } : {}) })!.n;
5067
5168 /**
5269 * Search is a LIKE over title, blurb and sku. The needle is escaped so a
5370 * shopper typing "50%" searches for "50%" and not for everything.
5471 */
55 export const search = (query: string, limit = 40): ProductWithPrice[] => {
56 const needle = `%${query.trim().replace(/[\\%_]/g, (c) => "\\" + c)}%`;
57 if (query.trim() === "") return [];
58 return db
59 .query<ProductWithPrice, [string, number]>(
60 `SELECT ${PRODUCT_COLUMNS} FROM product p JOIN category c ON c.id = p.category_id
61 WHERE p.active = 1 AND (
72 const MATCHES = `p.active = 1 AND (
6273 p.title LIKE ?1 ESCAPE '\\' OR p.blurb LIKE ?1 ESCAPE '\\' OR p.description LIKE ?1 ESCAPE '\\'
6374 OR c.name LIKE ?1 ESCAPE '\\'
6475 OR EXISTS (SELECT 1 FROM variant v WHERE v.product_id = p.id AND v.sku LIKE ?1 ESCAPE '\\')
65 )
66 ORDER BY (p.title LIKE ?1 ESCAPE '\\') DESC, p.title LIMIT ?2`,
76 )`;
77
78 const needleFor = (query: string): string => `%${query.trim().replace(/[\\%_]/g, (c) => "\\" + c)}%`;
79
80 export const search = (query: string, limit = 40, offset = 0): ProductWithPrice[] => {
81 if (query.trim() === "") return [];
82 return db
83 .query<ProductWithPrice, [string, number, number]>(
84 `SELECT ${PRODUCT_COLUMNS} FROM product p JOIN category c ON c.id = p.category_id
85 WHERE ${MATCHES}
86 ORDER BY (p.title LIKE ?1 ESCAPE '\\') DESC, p.title LIMIT ?2 OFFSET ?3`,
6787 )
68 .all(needle, limit);
88 .all(needleFor(query), limit, offset);
6989 };
7090
91 export const countSearch = (query: string): number =>
92 query.trim() === ""
93 ? 0
94 : db
95 .query<{ n: number }, [string]>(
96 `SELECT COUNT(*) AS n FROM product p JOIN category c ON c.id = p.category_id WHERE ${MATCHES}`,
97 )
98 .get(needleFor(query))!.n;
99
71100 export const variantsOf = (productId: number, includeInactive = false): Variant[] =>
72101 db
73102 .query<Variant, [number]>(
src/context.ts +3 −1
@@ -9,6 +9,7 @@ import { html } from "./http.ts";
99 import { layout } from "./html.ts";
1010 import type { Applied } from "./codes.ts";
1111 import type { CartLine } from "./cart.ts";
12 import type { Peer } from "./peer.ts";
1213 import type { Session } from "./session.ts";
1314 import { returnTo } from "./who.ts";
1415 import type { Who } from "./who.ts";
@@ -32,7 +33,8 @@ export type Ctx = {
3233 setCookie: (value: string) => void;
3334 /** True when the app is served over https, so cookies can be Secure. */
3435 secure: boolean;
35 ip: string;
36 /** Where the request came from, as far as it can be proved: src/peer.ts. */
37 peer: Peer;
3638 };
3739
3840 export const cartCount = (cart: CartLine[]): number => cart.reduce((n, line) => n + line.quantity, 0);
src/html.ts +2 −1
@@ -90,7 +90,8 @@ export function layout(chrome: Chrome): string {
9090 <footer class="bottom"><div class="wrap">
9191 <p><span class="env" style="background: ${env === "production" ? "#16653d" : "#1d4ed8"}">${escape(where)}</span>
9292 ${commit ? `<span class="muted"> @ ${escape(commit)}</span>` : ""}
93 — a demonstration store: no goods ship and no money moves.</p>
93 — a demonstration store: no goods ship and no money moves.
94 <a href="/orders">Find an order</a></p>
9495 <p>${
9596 ssh
9697 ? `Same shop in a terminal: <code>${escape(ssh)}</code>`
src/mail.ts +1
@@ -67,6 +67,7 @@ export function receiptText(order: Order, items: OrderItem[], giftCardCodes: str
6767 ? "a gift card, in full"
6868 : "the mock gateway"
6969 }.`,
70 `Lost the link to this order? The "Find an order" page takes ${order.number} with this email address.`,
7071 "This is a demonstration store: no goods ship and no money moves.",
7172 ].join("\n");
7273 }
src/mine.ts added +36
@@ -0,0 +1,36 @@
1 /**
2 * The orders this browser has placed, in a signed cookie beside the cart.
3 *
4 * Numbers and nothing else: the access token that actually opens an order is
5 * read from the database on every render, so editing this cookie can only
6 * change which order number is being claimed — and a number alone opens
7 * nothing, because the link is built from the token in the row. Ten is as far
8 * back as a cookie should remember; older orders are found with the form at
9 * `/orders`, which asks for the email as well.
10 */

ping

ping

11
12 import { sign, unsign } from "./sign.ts";
13
14 export const COOKIE = "mine";
15 export const KEEP = 10;
16
17 /** The shape of an order number: "BZ-" and capitals or digits. */
18 const SHAPE = /^BZ-[A-Z0-9]{4,20}$/;
19 export const looksLikeNumber = (value: string): boolean => SHAPE.test(value);
20
21 export function encode(numbers: string[], secret: string): string {
22 return sign(secret, numbers.filter(looksLikeNumber).slice(0, KEEP).join(","));
23 }
24
25 export function decode(cookieValue: string | undefined, secret: string): string[] {
26 if (!cookieValue) return [];
27 const text = unsign(secret, cookieValue);
28 if (text === undefined) return [];
29 return text.split(",").filter(looksLikeNumber).slice(0, KEEP);
30 }
31
32 /** Newest first, each number once, never more than KEEP of them. */
33 export function remember(numbers: string[], number: string): string[] {
34 if (!looksLikeNumber(number)) return numbers;
35 return [number, ...numbers.filter((held) => held !== number)].slice(0, KEEP);
36 }
src/pages/checkout.ts +2 −1
@@ -223,7 +223,8 @@ ${itemsTable(items)}
223223 ${orderTotals(order, refunded)}
224224 <h2>Shipping to</h2>
225225 <p>${addressBlock(order)}<br><span class="muted">${escape(order.email)}</span></p>
226 <p class="muted">Keep this address: it is the only way back to this order.
226 <p class="muted">Keep this address — it is what opens this order. If you lose it,
227 <a href="/orders">find the order</a> again with its number and ${escape(order.email)}.
227228 ${who.role === "admin" ? `<a href="/admin/orders/${escape(order.number)}">Open in admin</a>.` : ""}</p>
228229 `;
229230 }
src/pages/find.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * "Find an order": the way back to an order once the link is gone.
3 *
4 * Two things are on this page — the orders this browser remembers placing,
5 * and a form that asks for an order number and the email it was placed with.
6 * Every link here carries the order's own access token, so the page that
7 * opens is the same one the shopper was sent to at checkout.
8 */
9
10 import { csrfField, escape } from "../html.ts";
11 import { formatCents } from "../money.ts";
12 import { statusPill } from "./checkout.ts";
13 import type { Order } from "../db.ts";
14 import type { Session } from "../session.ts";
15
16 const row = (order: Order): string => `<tr>
17 <td><a href="/orders/${escape(order.number)}?token=${escape(order.access_token)}">${escape(order.number)}</a></td>
18 <td class="muted">${escape(order.created_at)} UTC</td>
19 <td>${statusPill(order.status)}</td>
20 <td class="num price">${escape(formatCents(order.total_cents))}</td>
21 </tr>`;
22
23 export function find(
24 mine: Order[],
25 session: Session,
26 values: { number?: string; email?: string } = {},
27 error?: string,
28 ): string {
29 return `
30 <h1>Find an order</h1>
31 <p class="lede">An order is private, so it opens with its own link. Type the order number and the
32 email it was placed with and the link is handed back to you.</p>
33 ${error ? `<p class="notice bad">${escape(error)}</p>` : ""}
34 <form method="post" action="/orders/find" class="lookup">
35 ${csrfField(session)}
36 <div>
37 <label>Order number
38 <input name="number" value="${escape(values.number ?? "")}" maxlength="20" required
39 placeholder="BZ-XXXXXXXX" autocapitalize="characters" autocomplete="off">
40 </label>
41 </div>
42 <div>
43 <label>Email
44 <input name="email" type="email" value="${escape(values.email ?? "")}" maxlength="200" required
45 autocomplete="email">
46 </label>
47 <button class="btn" style="margin-top:.75rem">Find it</button>
48 </div>
49 </form>
50 ${
51 mine.length === 0
52 ? `<p class="muted" style="margin-top:2rem">No orders have been placed from this browser.</p>`
53 : `<h2 style="margin-top:2rem">From this browser</h2>
54 <table>
55 <thead><tr><th>Order</th><th>Placed</th><th>Status</th><th class="num">Total</th></tr></thead>
56 <tbody>${mine.map(row).join("\n")}</tbody>
57 </table>
58 <p class="muted">The last ${mine.length === 1 ? "order" : mine.length + " orders"} placed here, remembered in a
59 signed cookie that holds order numbers and nothing else. On a shared computer, drop it:</p>
60 <form method="post" action="/orders/forget">
61 ${csrfField(session)}
62 <button class="btn ghost">Forget these orders</button>
63 </form>`
64 }
65 `;
66 }
src/pages/storefront.ts +27 −4
@@ -41,20 +41,43 @@ ${grid(featured.filter((product) => product.category_slug === category.slug))}`,
4141 .join("\n")}
4242 `;
4343
44 export const category = (found: Category, products: ProductWithPrice[]): string => `
44 /** Where a listing is in a longer list: "page 2 of 4", with the way either side. */
45 export type Page = { number: number; size: number; total: number };
46
47 export const pageCount = (page: Page): number => Math.max(1, Math.ceil(page.total / page.size));
48
49 export function pager(path: string, page: Page): string {
50 const last = pageCount(page);
51 if (last <= 1) return "";
52 const link = (to: number, label: string): string =>
53 to < 1 || to > last
54 ? `<span class="btn ghost" aria-disabled="true">${escape(label)}</span>`
55 : `<a class="btn ghost" href="${escape(path)}${path.includes("?") ? "&" : "?"}page=${to}">${escape(label)}</a>`;
56 return `<nav class="row-actions" style="justify-content:center; margin-top:1.5rem" aria-label="Pages">
57 ${link(page.number - 1, "Previous")}
58 <span class="muted">Page ${page.number} of ${last}</span>
59 ${link(page.number + 1, "Next")}
60 </nav>`;
61 }
62
63 export const category = (found: Category, products: ProductWithPrice[], page: Page): string => `
4564 <h1>${escape(found.name)}</h1>
46 <p class="lede">${escape(found.blurb)}</p>
65 <p class="lede">${escape(found.blurb)} <span class="muted">${page.total} ${
66 page.total === 1 ? "thing" : "things"
67 }.</span></p>
4768 ${grid(products)}
69 ${pager(`/c/${encodeURIComponent(found.slug)}`, page)}
4870 `;
4971
50 export const search = (query: string, results: ProductWithPrice[]): string => `
72 export const search = (query: string, results: ProductWithPrice[], page: Page): string => `
5173 <h1>${query ? `“${escape(query)}”` : "Search"}</h1>
5274 <p class="lede">${
5375 query === ""
5476 ? "Type something into the box above."
55 : `${results.length} ${results.length === 1 ? "match" : "matches"}.`
77 : `${page.total} ${page.total === 1 ? "match" : "matches"}.`
5678 }</p>
5779 ${query === "" ? "" : grid(results)}
80 ${query === "" ? "" : pager(`/search?q=${encodeURIComponent(query)}`, page)}
5881 `;
5982
6083 export function product(
src/peer.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * Where a request came from, told apart from where it says it came from.
3 *
4 * A rate limit is only a limit if the caller cannot pick which bucket it
5 * spends, so a bucket is keyed on this and nothing else: the address the
6 * socket actually arrived on, which is the one thing here that is not the
7 * caller's to write.
8 *
9 * `X-Forwarded-For` is not read. It would be the useful thing — it names the
10 * shopper behind a proxy — but only if a proxy we trust appends to it, and
11 * nothing this app can see from the inside proves that one does. Anything a
12 * client sends arrives in that header too, and a hint nobody vouches for is
13 * the caller's own word: a limit keyed on the caller's own word is not a
14 * limit, which is what the leftmost entry made of every bucket here before.
15 * So it is dropped, and a bucket behind the forge's proxy is the whole shop's
16 * — see the README. If the forge ever says it appends a hop of its own, the
17 * rightmost entry becomes worth reading and this is the one file that changes.
18 *
19 * Nothing leaves here unchecked. The address keys a bucket and it is also
20 * written into `order_event` detail, where an auditor reads it, so it has to
21 * look like an address before either.
22 */
23
24 export type Peer = {
25 /** The socket's address. The caller cannot choose this one. */
26 address: string;
27 };
28
29 /** The longest an address can be: an IPv6 one with an IPv4 tail inside it. */
30 export const MAX = 45;
31
32 /**
33 * The value as an address, or null. Four octets of at most 255, or hex digits
34 * and colons; everything else — a hostname, a comma, a space, a word an
35 * auditor would misread as a note — is not an address and is dropped.
36 */
37 export function address(value: string | null | undefined): string | null {
38 const text = (value ?? "").trim();
39 if (text.length === 0 || text.length > MAX) return null;
40 if (/^\d{1,3}(\.\d{1,3}){3}$/.test(text))
41 return text.split(".").every((octet) => Number(octet) <= 255) ? text : null;
42 if (text.includes(":") && /^[0-9A-Fa-f:.]+$/.test(text)) return text;
43 return null;
44 }
45
46 /** `socket` is the address the request actually arrived on, or nothing yet. */
47 export const peerOf = (socket: string | null | undefined): Peer => ({ address: address(socket) ?? "local" });
src/ratelimit.ts +21 −10
@@ -1,23 +1,34 @@
11 /**
22 * Rate limits. A token bucket per key, in memory: this is one process, and a
3 * limit that resets when the app restarts is still a limit. Checkout, payment
4 * the admin sign-in and typing a code each get their own bucket so a busy shop cannot lock
5 * anyone out of the others.
3 * limit that resets when the app restarts is still a limit. Checkout, payment,
4 * typing a code and finding an order each get their own bucket so a busy shop
5 * cannot lock anyone out of the others.
6 *
7 * A bucket is keyed on the peer's address and nothing else (see src/peer.ts):
8 * on a header the caller writes it would be no limit at all. Behind the
9 * forge's proxy that address is the proxy's, so these are the shop's limits
10 * rather than each shopper's, and the capacities are set for a shop: enough
11 * that ordinary trade never meets them, low enough that guessing at a code or
12 * an order number runs out of tries long before it runs out of guesses.
613 */
714
15 import type { Peer } from "./peer.ts";
16
817 type Bucket = { tokens: number; at: number };
918 const buckets = new Map<string, Bucket>();
1019
1120 export type Limit = { name: string; capacity: number; perSeconds: number };
1221
13 export const CHECKOUT: Limit = { name: "checkout", capacity: 8, perSeconds: 60 };
14 export const PAYMENT: Limit = { name: "payment", capacity: 20, perSeconds: 60 };
22 export const CHECKOUT: Limit = { name: "checkout", capacity: 30, perSeconds: 60 };
23 export const PAYMENT: Limit = { name: "payment", capacity: 60, perSeconds: 60 };
1524 // Codes are guessable in principle, so trying them is the slowest thing here.
16 export const CODES: Limit = { name: "codes", capacity: 10, perSeconds: 120 };
25 export const CODES: Limit = { name: "codes", capacity: 30, perSeconds: 120 };
26 // So is an order number with an email beside it: a few tries, then wait.
27 export const LOOKUP: Limit = { name: "lookup", capacity: 30, perSeconds: 300 };
1728
1829 /** True when the caller may proceed; false when the bucket is empty. */
19 export function take(limit: Limit, who: string, now = Date.now()): boolean {
20 const key = `${limit.name}:${who}`;
30 export function take(limit: Limit, peer: Peer, now = Date.now()): boolean {
31 const key = `${limit.name}:${peer.address}`;
2132 const rate = limit.capacity / (limit.perSeconds * 1000);
2233 const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now };
2334 const refilled = Math.min(limit.capacity, bucket.tokens + (now - bucket.at) * rate);
@@ -31,8 +42,8 @@ export function take(limit: Limit, who: string, now = Date.now()): boolean {
3142 }
3243
3344 /** Seconds until one token is back, for Retry-After. */
34 export function retryAfter(limit: Limit, who: string, now = Date.now()): number {
35 const bucket = buckets.get(`${limit.name}:${who}`);
45 export function retryAfter(limit: Limit, peer: Peer, now = Date.now()): number {
46 const bucket = buckets.get(`${limit.name}:${peer.address}`);
3647 if (!bucket) return 0;
3748 const rate = limit.capacity / (limit.perSeconds * 1000);
3849 const refilled = Math.min(limit.capacity, bucket.tokens + (now - bucket.at) * rate);
src/routes.ts +3
@@ -37,6 +37,9 @@ export const routes: Route[] = [
3737 { method: "POST", pattern: "/pay/:number/stripe", handler: shop.payStripe },
3838 { method: "POST", pattern: "/webhooks/stripe", handler: shop.stripeWebhook, csrf: false, raw: true },
3939
40 { method: "GET", pattern: "/orders", handler: (ctx) => shop.findForm(ctx) },
41 { method: "POST", pattern: "/orders/find", handler: shop.findOrder },
42 { method: "POST", pattern: "/orders/forget", handler: shop.forgetOrders },
4043 { method: "GET", pattern: "/orders/:number", handler: shop.orderPage },
4144 { method: "GET", pattern: "/orders/:number/receipt", handler: shop.receiptPage },
4245
src/server.test.ts +169 −1
@@ -60,7 +60,12 @@ class Shopper {
6060 return (await this.get(path)).text();
6161 }
6262
63 async post(path: string, fields: Record<string, string>, csrf?: string): Promise<Response> {
63 async post(
64 path: string,
65 fields: Record<string, string>,
66 csrf?: string,
67 extra: Record<string, string> = {},
68 ): Promise<Response> {
6469 const body = new URLSearchParams(fields);
6570 if (csrf !== undefined) body.set("csrf", csrf);
6671 const response = await fetch(base + path, {
@@ -71,6 +76,7 @@ class Shopper {
7176 "sec-fetch-site": "same-origin",
7277 "content-type": "application/x-www-form-urlencoded",
7378 ...this.forge(),
79 ...extra,
7480 },
7581 redirect: "manual",
7682 });
@@ -748,3 +754,165 @@ test("an admin adds a product and a variant, and the shop sells it", async () =>
748754 expect(shop).toContain("KIT-SPOON-1");
749755 expect(stockOf("KIT-SPOON-1")).toBe(5);
750756 });
757
758 test("an order is found again with its number and the email it was placed with", async () => {
759 limit.forget();
760 const shopper = new Shopper();
761 const { number, token } = await buy(shopper, "COF-GUJI-250", 1);
762
763 // Another browser knows nothing until it names both the order and the email.
764 const stranger = new Shopper();
765 expect((await stranger.get(`/orders/${number}`)).status).toBe(404);
766 const form = await stranger.page("/orders");
767 const wrong = await stranger.post(
768 "/orders/find",
769 { number, email: "[email protected]" },
770 Shopper.tokenIn(form),
771 );
772 expect(wrong.status).toBe(200);
773 expect(await wrong.text()).toContain("No order here matches");
774
775 const found = await stranger.post("/orders/find", { number, email: "[email protected]" }, Shopper.tokenIn(form));
776 expect(found.status).toBe(303);
777 const where = orderAt(found.headers.get("location")!);
778 expect(where.number).toBe(number);
779 expect(where.token).toBe(token);
780 expect(await stranger.page(found.headers.get("location")!)).toContain(number);
781 limit.forget();
782 });
783
784 test("a number that was never issued is refused in the same words as a wrong email", async () => {
785 limit.forget();
786 const shopper = new Shopper();
787 const form = await shopper.page("/orders");
788 const refusal = async (fields: Record<string, string>): Promise<string> =>
789 (await shopper.post("/orders/find", fields, Shopper.tokenIn(form))).text();
790 const invented = await refusal({ number: "BZ-ZZZZZZZZ", email: "[email protected]" });
791 const { number } = await buy(shopper, "COF-HOUSE-250", 1);
792 const mismatched = await refusal({ number, email: "[email protected]" });
793 expect(invented).toContain("No order here matches");
794 expect(mismatched).toContain("No order here matches");
795 limit.forget();
796 });
797
798 test("looking an order up is rate limited", async () => {
799 limit.forget();
800 const shopper = new Shopper();
801 const form = await shopper.page("/orders");
802 const token = Shopper.tokenIn(form);
803 let sawLimit = false;
804 for (let attempt = 0; attempt < limit.LOOKUP.capacity + 2; attempt++) {
805 const response = await shopper.post("/orders/find", { number: "BZ-ZZZZZZZZ", email: "[email protected]" }, token);
806 if (response.status === 429) {
807 sawLimit = true;
808 expect(Number(response.headers.get("retry-after"))).toBeGreaterThan(0);
809 break;
810 }
811 }
812 expect(sawLimit).toBe(true);
813 limit.forget();
814 });
815
816 test("a spent limit cannot be refilled by changing x-forwarded-for", async () => {
817 limit.forget();
818 const shopper = new Shopper();
819 const token = Shopper.tokenIn(await shopper.page("/orders"));
820 const guess = (from: string): Promise<Response> =>
821 shopper.post("/orders/find", { number: "BZ-ZZZZZZZZ", email: "[email protected]" }, token, {
822 "x-forwarded-for": from,
823 });
824
825 // Spend the bucket claiming to be one address...
826 for (let attempt = 0; attempt < limit.LOOKUP.capacity + 2; attempt++) await guess("203.0.113.9");
827 // ...then claim to be another, and a third, and send no claim at all.
828 expect((await guess("198.51.100.77")).status).toBe(429);
829 expect((await guess("2001:db8::1")).status).toBe(429);
830 expect(
831 (await shopper.post("/orders/find", { number: "BZ-ZZZZZZZZ", email: "[email protected]" }, token)).status,
832 ).toBe(429);
833 limit.forget();
834 });
835
836 test("the orders a browser placed are listed at /orders, and only for that browser", async () => {
837 limit.forget();
838 const shopper = new Shopper();
839 const { number, token } = await buy(shopper, "COF-GUJI-250", 1);
840 const mine = await shopper.page("/orders");
841 expect(mine).toContain(number);
842 expect(mine).toContain(`/orders/${number}?token=${token}`);
843 expect(await new Shopper().page("/orders")).not.toContain(number);
844 limit.forget();
845 });
846
847 test("the order-lookup form has a class of its own, not the header search's", async () => {
848 const page = await new Shopper().page("/orders");
849 expect(page).toContain(`action="/orders/find" class="lookup"`);
850 // `find` is the search box in the header, and it is the only one on the page.
851 expect(page.match(/class="find"/g)?.length).toBe(1);
852 });
853
854 test("a shared browser can forget the orders it remembers", async () => {
855 limit.forget();
856 const shopper = new Shopper();
857 const { number } = await buy(shopper, "COF-GUJI-250", 1);
858 const listed = await shopper.page("/orders");
859 expect(listed).toContain(number);
860
861 const forgotten = await shopper.post("/orders/forget", {}, Shopper.tokenIn(listed));
862 expect(forgotten.status).toBe(303);
863 const after = await shopper.page("/orders");
864 expect(after).not.toContain(number);
865 expect(after).toContain("No orders have been placed from this browser");
866 // Forgetting is a POST from this page and nowhere else.
867 expect((await shopper.post("/orders/forget", {})).status).toBe(403);
868 limit.forget();
869 });
870
871 test("a variant that is not the named product's own is not added to the cart", async () => {
872 const shopper = new Shopper();
873 const product = await shopper.page("/p/stoneware-mug");
874 const refused = await shopper.post(
875 "/cart/add",
876 { variant: String(variantId("COF-HOUSE-250")), quantity: "1", slug: "stoneware-mug" },
877 Shopper.tokenIn(product),
878 );
879 expect(refused.status).toBe(303);
880 expect(refused.headers.get("location")).toBe("/p/stoneware-mug");
881 expect(await shopper.page("/cart")).not.toContain("House Espresso");
882 });
883
884 test("a category longer than one page is paged, and every page is reachable", async () => {
885 const catalog = await import("./catalog.ts");
886 const categoryId = db
887 .query<{ id: number }, []>(
888 "INSERT INTO category (slug, name, blurb) VALUES ('bulk', 'Bulk', 'Many of a muchness.') RETURNING id",
889 )
890 .get()!.id;
891 for (let i = 1; i <= 26; i++)
892 catalog.createProduct({
893 title: `Bulk Item ${String(i).padStart(2, "0")}`,
894 slug: `bulk-item-${i}`,
895 blurb: "One of many.",
896 description: "",
897 category_id: categoryId,
898 active: true,
899 });
900
901 const first = await fetch(base + "/c/bulk").then((r) => r.text());
902 expect(first).toContain("Page 1 of 2");
903 expect(first).toContain("Bulk Item 01");
904 expect(first).not.toContain("Bulk Item 26");
905 const second = await fetch(base + "/c/bulk?page=2").then((r) => r.text());
906 expect(second).toContain("Page 2 of 2");
907 expect(second).toContain("Bulk Item 26");
908 expect(second).not.toContain("Bulk Item 01");
909
910 // A page past the end is the last page; a page that is not a number is the first.
911 expect(await fetch(base + "/c/bulk?page=99").then((r) => r.text())).toContain("Page 2 of 2");
912 expect(await fetch(base + "/c/bulk?page=not-a-number").then((r) => r.text())).toContain("Page 1 of 2");
913
914 const found = await fetch(base + "/search?q=Bulk+Item").then((r) => r.text());
915 expect(found).toContain("26 matches");
916 expect(found).toContain("Page 1 of 2");
917 expect(await fetch(base + "/search?q=Bulk+Item&page=2").then((r) => r.text())).toContain("Bulk Item 26");
918 });
src/server.ts +3 −2
@@ -25,6 +25,7 @@ import {
2525 import { loadSecret } from "./sign.ts";
2626 import { match } from "./routes.ts";
2727 import { notFound } from "./shop.ts";
28 import { peerOf } from "./peer.ts";
2829 import { seed } from "./catalog.ts";
2930 import { seed as seedDiscounts } from "./discounts.ts";
3031 import { seed as seedTax } from "./tax.ts";
@@ -162,7 +163,6 @@ function context(
162163 outgoing: string[],
163164 self: { requestIP(request: Request): { address: string } | null },
164165 ): Ctx {
165 const forwarded = (request.headers.get("x-forwarded-for") ?? "").split(",")[0]?.trim();
166166 return {
167167 request,
168168 url,
@@ -176,7 +176,8 @@ function context(
176176 cookies,
177177 setCookie: (value: string) => outgoing.push(value),
178178 secure: publicOrigin.startsWith("https:"),
179 ip: forwarded || self.requestIP(request)?.address || "local",
179 // The socket, never a header: see src/peer.ts for why nothing else counts.
180 peer: peerOf(self.requestIP(request)?.address),
180181 };
181182 }
182183
src/shop.ts +111 −14
@@ -13,15 +13,27 @@ import * as discounts from "./discounts.ts";
1313 import * as gateway from "./gateway.ts";
1414 import * as giftcards from "./giftcards.ts";
1515 import * as limit from "./ratelimit.ts";
16 import * as mine from "./mine.ts";
1617 import * as orders from "./orders.ts";
1718 import * as pages from "./pages/checkout.ts";
1819 import * as storefront from "./pages/storefront.ts";
19 import { categories, categoryBySlug, productBySlug, products, search, variantById, variantsOf } from "./catalog.ts";
20 import {
21 categories,
22 categoryBySlug,
23 countProducts,
24 countSearch,
25 productBySlug,
26 products,
27 search,
28 variantById,
29 variantsOf,
30 } from "./catalog.ts";
2031 import { field, handOff, intField, looksLikeEmail, redirect, text } from "./http.ts";
32 import { find as findPage } from "./pages/find.ts";
2133 import { render, type Ctx } from "./context.ts";
2234 import { page as cartPage } from "./pages/cart.ts";
2335 import { chosen } from "./gateway.ts";
24 import { equals, sign, unsign } from "./sign.ts";
36 import { equals, hmac, sign, unsign } from "./sign.ts";
2537 import { forOrder } from "./mail.ts";
2638 import { dueCents, parseQuantity } from "./money.ts";
2739 import { escape } from "./html.ts";
@@ -44,24 +56,52 @@ const saveCodes = (ctx: Ctx, applied: codes.Applied): void => {
4456 ctx.codes = applied;
4557 };
4658
59 /** The order numbers this browser has placed, so `/orders` can list them. */
60 const myNumbers = (ctx: Ctx): string[] => mine.decode(ctx.cookies[mine.COOKIE], ctx.secret);
61
62 const rememberOrder = (ctx: Ctx, number: string): void => {
63 ctx.setCookie(jar(ctx, mine.COOKIE, mine.encode(mine.remember(myNumbers(ctx), number), ctx.secret)));
64 };
65
66 /** A shared browser should be able to stop being this shopper's. */
67 export function forgetOrders(ctx: Ctx): Response {
68 ctx.setCookie(`${mine.COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${ctx.secure ? "; Secure" : ""}`);
69 return redirect("/orders");
70 }
71
4772 // --- browsing ---------------------------------------------------------------
4873
4974 export const home = (ctx: Ctx): Response =>
5075 render(ctx, { title: "Shop" }, storefront.home(categories(), products({ limit: 60 })));
5176
77 /** A listing is this long; the rest is a page away, never a page that never ends. */
78 const PAGE_SIZE = 24;
79
80 /** "?page=3", clamped: a page number is 1..last and anything else is page 1. */
81 const pageIn = (ctx: Ctx, total: number): storefront.Page => {
82 const asked = Number(ctx.url.searchParams.get("page") ?? "1");
83 const last = Math.max(1, Math.ceil(total / PAGE_SIZE));
84 const number = Number.isInteger(asked) && asked >= 1 ? Math.min(asked, last) : 1;
85 return { number, size: PAGE_SIZE, total };
86 };
87
5288 export function category(ctx: Ctx): Response {
5389 const found = categoryBySlug(ctx.params.slug ?? "");
5490 if (!found) return notFound(ctx);
55 return render(
56 ctx,
57 { title: found.name, current: `c:${found.slug}` },
58 storefront.category(found, products({ category: found.slug })),
59 );
91 const page = pageIn(ctx, countProducts({ category: found.slug }));
92 const listing = products({ category: found.slug, limit: page.size, offset: (page.number - 1) * page.size });
93 return render(ctx, { title: found.name, current: `c:${found.slug}` }, storefront.category(found, listing, page));
6094 }
6195
6296 export function searchPage(ctx: Ctx): Response {
6397 const query = (ctx.url.searchParams.get("q") ?? "").slice(0, 80).trim();
64 return render(ctx, { title: query ? `Search: ${query}` : "Search", query }, storefront.search(query, search(query)));
98 const page = pageIn(ctx, countSearch(query));
99 const results = search(query, page.size, (page.number - 1) * page.size);
100 return render(
101 ctx,
102 { title: query ? `Search: ${query}` : "Search", query },
103 storefront.search(query, results, page),
104 );
65105 }
66106
67107 export function product(ctx: Ctx): Response {
@@ -89,7 +129,12 @@ export function addToCart(ctx: Ctx): Response {
89129 const quantity = parseQuantity(ctx.form?.get("quantity"), 99);
90130 const slug = field(ctx.form, "slug", 60);
91131 const variant = variantId === undefined ? null : variantById(variantId);
92 if (!variant || variant.active !== 1 || quantity < 1) return redirect(slug ? `/p/${encodeURIComponent(slug)}` : "/");
132 // When the form names a product, the variant has to be one of that
133 // product's own: a pair that does not agree did not come from that form.
134 const named = slug === "" || !variant ? null : productBySlug(slug);
135 const mismatched = named !== null && named.id !== variant!.product_id;
136 if (!variant || variant.active !== 1 || quantity < 1 || mismatched)
137 return redirect(slug ? `/p/${encodeURIComponent(slug)}` : "/");
93138 saveCart(ctx, cart.add(ctx.cart, variant.id, quantity));
94139 return redirect("/cart");
95140 }
@@ -119,7 +164,7 @@ export function cartView(ctx: Ctx, error?: string): Response {
119164 * neither is believed further than its own row in the database.
120165 */
121166 export function applyCode(ctx: Ctx): Response {
122 if (!limit.take(limit.CODES, ctx.ip)) return tooMany(ctx, limit.retryAfter(limit.CODES, ctx.ip), "code");
167 if (!limit.take(limit.CODES, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.CODES, ctx.peer), "code");
123168 const typed = field(ctx.form, "code", 40);
124169 const back = field(ctx.form, "back", 20) === "checkout" ? "/checkout" : "/cart";
125170 if (typed === "") return redirect(back);
@@ -173,8 +218,8 @@ export function checkoutForm(ctx: Ctx, error?: string, values: Record<string, st
173218 }
174219
175220 export function placeOrder(ctx: Ctx): Response {
176 if (!limit.take(limit.CHECKOUT, ctx.ip))
177 return tooMany(ctx, limit.retryAfter(limit.CHECKOUT, ctx.ip), "checkout");
221 if (!limit.take(limit.CHECKOUT, ctx.peer))
222 return tooMany(ctx, limit.retryAfter(limit.CHECKOUT, ctx.peer), "checkout");
178223 const values = {
179224 email: field(ctx.form, "email", 200),
180225 name: field(ctx.form, "name", 120),
@@ -208,6 +253,7 @@ export function placeOrder(ctx: Ctx): Response {
208253 if (!result.ok) return checkoutForm(ctx, result.error, values);
209254 saveCart(ctx, []);
210255 saveCodes(ctx, codes.NONE);
256 rememberOrder(ctx, result.order.number);
211257 const where = dueCents(result.order) === 0 ? "orders" : "pay";
212258 return redirect(`/${where}/${result.order.number}?token=${encodeURIComponent(result.order.access_token)}`);
213259 }
@@ -239,7 +285,7 @@ export function payPage(ctx: Ctx, error?: string): Response {
239285
240286 /** The mock gateway: it answers whatever the button asked it to answer. */
241287 export function payMock(ctx: Ctx): Response {
242 if (!limit.take(limit.PAYMENT, ctx.ip)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.ip), "payment");
288 if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment");
243289 const order = openOrder(ctx, "form");
244290 if (!order) return notFound(ctx);
245291 if (order.status !== "pending_payment")
@@ -268,7 +314,7 @@ const payPageFor = (ctx: Ctx, order: Order, error: string): Response =>
268314
269315 /** Stripe: build a Checkout Session and hand the browser to it. */
270316 export async function payStripe(ctx: Ctx): Promise<Response> {
271 if (!limit.take(limit.PAYMENT, ctx.ip)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.ip), "payment");
317 if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment");
272318 const order = openOrder(ctx, "form");
273319 if (!order) return notFound(ctx);
274320 if (order.status !== "pending_payment")
@@ -301,6 +347,57 @@ export async function orderPage(ctx: Ctx): Promise<Response> {
301347 );
302348 }
303349
350 /**
351 * "Find an order". The orders this browser remembers are listed with their
352 * own links; anything older is found by naming the order and the email it was
353 * placed with.
354 */
355 export function findForm(
356 ctx: Ctx,
357 error?: string,
358 values: { number?: string; email?: string } = {},
359 ): Response {
360 const held = myNumbers(ctx)
361 .map((number) => orders.byNumber(number))
362 .filter((order): order is Order => order !== null);
363 return render(ctx, { title: "Find an order" }, findPage(held, ctx.session, values, error));
364 }
365
366 /**
367 * The number and the email must both be right, and a wrong pair is refused in
368 * the same words whether or not the order exists: this form must not be a way
369 * to learn which order numbers are real. The email is compared as a keyed
370 * digest, so neither its length nor its first wrong letter times differently,
371 * and a number that matches nothing is digested against a fixed string so
372 * that both paths do the same work as well as say the same thing.
373 */
374 /**
375 * Stands in for the email of an order that is not there. The email on the form
376 * has already been checked for an `@`, so nothing a shopper can type digests
377 * to this — it is only here so a miss does the same work as a match.
378 */
379 const NO_SUCH_EMAIL = "no-such-order";
380
381 export function findOrder(ctx: Ctx): Response {
382 if (!limit.take(limit.LOOKUP, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.LOOKUP, ctx.peer), "lookup");
383 const number = field(ctx.form, "number", 20).toUpperCase();
384 const email = field(ctx.form, "email", 200);
385 const values = { number, email };
386 if (!mine.looksLikeNumber(number) || !looksLikeEmail(email))
387 return findForm(ctx, "An order number looks like BZ-XXXXXXXX, with the email it was placed with.", values);
388
389 const order = orders.byNumber(number);
390 const digest = (value: string): string => hmac(ctx.secret, `order-email:${value.trim().toLowerCase()}`);
391 const matched = equals(digest(email), digest(order?.email ?? NO_SUCH_EMAIL));
392 if (!order || !matched)
393 return findForm(ctx, "No order here matches that number and email address.", values);
394
395 // The address, not the header: what goes in the trail has to be a fact.
396 orders.note(order.id, "looked-up", `found from ${ctx.peer.address}`);
397 rememberOrder(ctx, order.number);
398 return redirect(`/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`);
399 }
400
304401 export async function receiptPage(ctx: Ctx): Promise<Response> {
305402 const order = openOrder(ctx, "query");
306403 if (!order) return notFound(ctx);
src/style.ts +2 −1
@@ -59,6 +59,7 @@ p.lede { color: var(--dim); margin: 0 0 1.5rem; }
5959 .pill.warn { color: var(--warn); border-color: currentColor; }
6060 .product { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2rem; align-items: start; }
6161 .product .art { border-radius: 1rem; aspect-ratio: 1; font-size: 3rem; }
62 .lookup { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2rem; align-items: end; }
6263 .notice { border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: .5rem; padding: .7rem .9rem; margin: 0 0 1.25rem; background: #8881; }
6364 .notice.bad { border-left-color: var(--warn); }
6465 table { width: 100%; border-collapse: collapse; font-size: .95rem; }
@@ -82,7 +83,7 @@ footer.bottom code { background: #8881; padding: .1rem .35rem; border-radius: .3
8283 .admin nav { display: flex; gap: 1rem; font-size: .95rem; border-bottom: 1px solid var(--line); padding-bottom: .75rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
8384 .stack { display: grid; gap: .9rem; max-width: 34rem; }
8485 .row-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
85 @media (max-width: 46rem) { .product { grid-template-columns: 1fr; } .fields { grid-template-columns: 1fr; } }
86 @media (max-width: 46rem) { .product, .lookup { grid-template-columns: 1fr; } .fields { grid-template-columns: 1fr; } }
8687 `.trim();
8788
8889 export const CSS_PATH = `/static/bazaar.${createHash("sha256").update(CSS).digest("hex").slice(0, 8)}.css`;
src/tty.ts +22 −5
@@ -8,8 +8,9 @@
88 * stock and refunding are an admin's to do, here as on the web.
99 *
1010 * 1/2/3 switch between stock, orders and the outbox. j/k move, s moves stock
11 * with a reason, r refunds what is left of an order and puts it back on the
12 * shelf, q leaves. Plain ANSI, no dependencies.
11 * with a reason, / finds an order by number, name or email, r refunds what is
12 * left of an order and puts it back on the shelf, q leaves. Plain ANSI, no
13 * dependencies.
1314 */
1415
1516 import { db } from "./db.ts";
@@ -56,8 +57,13 @@ let tab: "stock" | "orders" | "outbox" = "stock";
5657 let cursor = 0;
5758 let prompt: { label: string; text: string; run: (value: string) => string } | undefined;
5859 let message = "";
60 // What `/` is narrowing the orders tab to: a number, a name or an email,
61 // the same needle /admin/orders takes.
62 let filter = "";
5963
60 const rowsFor = (): unknown[] => (tab === "stock" ? stockRows() : tab === "orders" ? recent(100) : outbox(50));
64 const orderRows = (): Order[] => recent(100, filter);
65
66 const rowsFor = (): unknown[] => (tab === "stock" ? stockRows() : tab === "orders" ? orderRows() : outbox(50));
6167
6268 function detail(order: Order): string[] {
6369 const lines = [""];
@@ -118,7 +124,7 @@ function draw(): void {
118124 tab === "stock"
119125 ? `1/2/3 tabs · j/k move${isAdmin ? " · s move stock" : ""} · h ledger · q quit`
120126 : tab === "orders"
121 ? `1/2/3 tabs · j/k move${isAdmin ? " · r refund the rest and restock" : ""} · q quit`
127 ? `1/2/3 tabs · j/k move · / find${filter ? ` (“${filter}”)` : ""}${isAdmin ? " · r refund the rest and restock" : ""} · q quit`
122128 : "1/2/3 tabs · j/k move · q quit",
123129 )
124130 : `${prompt.label} ${prompt.text}${esc}7m ${esc}0m`,
@@ -141,8 +147,17 @@ function moveStock(value: string): string {
141147 return `${row.sku}: ${delta > 0 ? "+" : ""}${delta} — ${reason}`;
142148 }
143149
150 /** `/` on the orders tab: the needle stays until it is cleared with an empty one. */
151 function findOrders(value: string): string {
152 filter = value.trim().slice(0, 60);
153 cursor = 0;
154 const found = orderRows().length;
155 if (filter === "") return "Showing every order.";
156 return found === 0 ? `Nothing matches “${filter}”.` : `${found} order${found === 1 ? "" : "s"} match “${filter}”.`;
157 }
158
144159 function refundRest(reason: string): string {
145 const order = recent(100)[cursor];
160 const order = orderRows()[cursor];
146161 if (!order) return "No order selected.";
147162 const left = refundableCents(order);
148163 if (left <= 0) return `${order.number} has nothing left to refund.`;
@@ -187,6 +202,8 @@ process.stdin.on("data", (key: string) => {
187202 if (key === "s" && tab === "stock")
188203 if (isAdmin) prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock };
189204 else message = "Moving stock is for an admin.";
205 if (key === "/" && tab === "orders")
206 prompt = { label: "Find an order (number, name or email):", text: filter, run: findOrders };
190207 if (key === "r" && tab === "orders")
191208 if (isAdmin) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest };
192209 else message = "Refunding is for an admin.";
src/units.test.ts +43 −6
@@ -11,6 +11,8 @@ const { encode, decode, put, add, quote } = await import("./cart.ts");
1111 const { sign, unsign, loadSecret } = await import("./sign.ts");
1212 const { apiBase, verifyWebhook, chosen } = await import("./gateway.ts");
1313 const limit = await import("./ratelimit.ts");
14 const mine = await import("./mine.ts");
15 const peer = await import("./peer.ts");
1416 const { escape } = await import("./html.ts");
1517 const catalog = await import("./catalog.ts");
1618 const discounts = await import("./discounts.ts");
@@ -245,19 +247,54 @@ test("a bucket empties and refills", () => {
245247 limit.forget();
246248 const rule = { name: "test", capacity: 2, perSeconds: 60 };
247249 const now = Date.now();
248 expect(limit.take(rule, "someone", now)).toBe(true);
249 expect(limit.take(rule, "someone", now)).toBe(true);
250 expect(limit.take(rule, "someone", now)).toBe(false);
251 expect(limit.retryAfter(rule, "someone", now)).toBeGreaterThan(0);
252 expect(limit.take(rule, "someone-else", now)).toBe(true);
253 expect(limit.take(rule, "someone", now + 60_000)).toBe(true);
250 const someone = { address: "198.51.100.1" };
251 const else_ = { address: "198.51.100.2" };
252 expect(limit.take(rule, someone, now)).toBe(true);
253 expect(limit.take(rule, someone, now)).toBe(true);
254 expect(limit.take(rule, someone, now)).toBe(false);
255 expect(limit.retryAfter(rule, someone, now)).toBeGreaterThan(0);
256 expect(limit.take(rule, else_, now)).toBe(true);
257 expect(limit.take(rule, someone, now + 60_000)).toBe(true);
254258 limit.forget();
255259 });
256260
261 test("a peer is the socket's address, checked for the shape of one", () => {
262 expect(peer.peerOf("127.0.0.1")).toEqual({ address: "127.0.0.1" });
263 expect(peer.peerOf(undefined)).toEqual({ address: "local" });
264 // Not an address is not a bucket key and not a line in an audit trail.
265 expect(peer.peerOf("203.0.113.9 refunded by an admin")).toEqual({ address: "local" });
266
267 expect(peer.address("192.168.0.1")).toBe("192.168.0.1");
268 expect(peer.address(" ::ffff:127.0.0.1 ")).toBe("::ffff:127.0.0.1");
269 expect(peer.address("999.1.1.1")).toBeNull();
270 expect(peer.address("evil.example.com")).toBeNull();
271 expect(peer.address("1.2.3.4 refunded by an admin")).toBeNull();
272 expect(peer.address("1".repeat(peer.MAX + 1))).toBeNull();
273 expect(peer.address(null)).toBeNull();
274 });
275
257276 test("output is escaped", () => {
258277 expect(escape(`<script>"&'`)).toBe("&lt;script&gt;&quot;&amp;&#39;");
259278 });
260279
280 test("the orders a browser remembers are signed, deduplicated and capped", () => {
281 const secret = "a-secret-long-enough-for-hmac";
282 const remembered = ["BZ-AAAABBBB", "BZ-CCCCDDDD"].reduce(mine.remember, [] as string[]);
283 expect(remembered).toEqual(["BZ-CCCCDDDD", "BZ-AAAABBBB"]);
284 expect(mine.remember(remembered, "BZ-AAAABBBB")).toEqual(["BZ-AAAABBBB", "BZ-CCCCDDDD"]);
285 expect(mine.remember(remembered, "not-an-order")).toEqual(remembered);
286
287 let many: string[] = [];
288 for (let i = 0; i < mine.KEEP + 5; i++) many = mine.remember(many, `BZ-ORDER${String(i).padStart(3, "0")}`);
289 expect(many.length).toBe(mine.KEEP);
290
291 const cookie = mine.encode(remembered, secret);
292 expect(mine.decode(cookie, secret)).toEqual(remembered);
293 expect(mine.decode(cookie, "another-secret-entirely-here")).toEqual([]);
294 expect(mine.decode(cookie.replace(/^./, "Z"), secret)).toEqual([]);
295 expect(mine.decode(undefined, secret)).toEqual([]);
296 });
297
261298 test("the settings the admin password left behind are deleted, and nothing else is", () => {
262299 setSetting("admin_demo_password", "whatever-it-was");
263300 setSetting("admin_salt", "and-its-salt");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs