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

Discount codes, gift cards and tax #6

Merged opened this in answer to

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

Merged

ledger/bazaar:ledger/3-discount-codes-gift-cards-and-tax → main 12 commits fork 4 threads · 4 unresolved

  • Merged. ledger/3-discount-codes-gift-cards-and-tax was merged into main as f1ec6cb.

Review

Sign in to review this change.
20 files changed +1323 −94
README.md +85 −25
@@ -2,11 +2,12 @@
22
33 A small shop: a catalog with categories and search, product pages with
44 variants and stock, a cart in a signed cookie, discount codes and gift cards,
5 tax by where the parcel goes, checkout with an address, a payment step,
6 receipts, and an `/admin` for products, inventory, codes, cards, tax and
7 orders with refunds. Every price is an integer number of cents, every form
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
5 tax by where the parcel goes, a gift card balance window, checkout with an
6 address, a payment step, receipts, and an `/admin` for products, inventory,
7 codes, cards, tax and orders with refunds. Every price is an integer number
8 of cents, every form carries a CSRF token, and checkout, payment, typing a
9 code, reading a gift card's balance and finding an order are each rate
10 limited on their own. A category or a search longer than 24 things is
1011 paged, so a listing is never a page without an end and never hides what is
1112 past the end of it.
1213
@@ -52,10 +53,16 @@ takes off.
5253 - **Discount codes** are a percent, a fixed number of cents, or free
5354 shipping, with an optional minimum, a start and expiry, and a cap on how
5455 many times they can be used. A discount applies to goods only — money is
55 not bought at a discount — and the cap is taken with a conditional `UPDATE`
56 inside the order's transaction, so a code with one use left goes to one
57 person. Every use, and every use given back when an order is cancelled, is
58 a row in `discount_redemption`. `WELCOME10` and `SHIPFREE` are seeded.
56 not bought at a discount — and every limit it carries is checked again by
57 the conditional `UPDATE` inside the order's transaction: whether it is on,
58 whether now is inside its window, and whether a use is left. A code with
59 one use left goes to one person, and a code that expires between the cart
60 and the order is not taken anyway. Every use, and every use given back when an order is cancelled, is
61 a row in `discount_redemption`. An expiry is stored as a stamp and compared
62 as text, so it has to be a real day: `discounts.endOfDay` parses it and
63 round-trips it, and the form and the terminal both refuse `2026-99-99`
64 rather than store a code that outlasts every date there is.
65 `WELCOME10` and `SHIPFREE` are seeded.
5966 - **Gift cards** are sold as an ordinary product (`/p/gift-card`) and issued
6067 when the order is paid; the code is printed once, on the receipt. Only the
6168 code's HMAC and its last four characters are stored, so the table cannot be
@@ -63,7 +70,18 @@ takes off.
6370 `gift_card_entry`, and spending is conditional on the balance being there,
6471 so two checkouts racing for the last $5 cannot both win and a balance never
6572 goes below zero. What a card cannot cover is left for the gateway; a card
66 that covers the lot means there is no gateway to ask.
73 that covers the lot means there is no gateway to ask. `/gift-cards` shows
74 whoever holds a code what is left on it and every movement of it: the code
75 is asked for with a POST so it never lands in a URL or a log, the page has
76 its own rate limit so reading a balance never costs a shopper the right to
77 apply a code at the till, and a code the shop does not know is refused in
78 the same words as one that is empty. Each movement is named in the shop's
79 own fixed words — "Issued", "Spent on an order", "Taken off the card" —
80 never the note an admin typed to themselves when they voided it. The window
81 shows the last twenty movements and says so when there are more, and it
82 offers to spend the card only while it is live and has something on it.
83 The confirmation page says, next to the gift card line, that the code is on
84 the receipt and that this is the only copy.
6785 - **Tax** is a table of rates in basis points by country and region
6886 (`/admin/tax`), matched on the address the shopper typed — the exact region
6987 first, then the country, then a default. It is taken on goods after the
@@ -104,7 +122,7 @@ that way back, and it has two halves:
104122 A limit only limits if the caller cannot choose which bucket it spends, so
105123 `src/peer.ts` decides where a request came from and a bucket is keyed on that
106124 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
125 read on the strength of its own existence. It would be the useful thing — it names the shopper behind a proxy — but
108126 only if a proxy we trust appends to it, and nothing this app can see from the
109127 inside proves that one does. A client can send that header itself, and a hint
110128 nobody vouches for is the caller's own word; keyed on the leftmost entry, as
@@ -113,11 +131,20 @@ this app used to be, every bucket here was ten tries per invented header.
113131 The consequence is deliberate and worth knowing: behind the forge's proxy every
114132 shopper arrives on the same address, so these are the **shop's** limits rather
115133 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.
134 shop — 30 checkouts a minute, 30 codes and 30 balance checks every two
135 minutes, 30 order lookups every five. Ordinary trade never meets them;
136 guessing at a gift card code or
137 an order number runs out of tries long before it runs out of guesses.
138
139 The one thing that can vouch for the header is the deployment, and only by
140 counting. `FORGE_APP_TRUSTED_HOPS` says how many proxies of ours stand in
141 front of the socket; a proxy appends the peer it saw to the **right** of the
142 chain, so with `1` the shopper is the right-hand entry, with `2` the one
143 before that, and everything further left — where a client writes what it likes
144 — is never read. Set it and the limits above go back to being each shopper's.
145 Unset, which is how the forge runs today, the header is not read at all, and
146 nothing is inferred from the shape of an address: a guess about who is proxying
147 is not a fact about it. `src/peer.ts` is the only file that reads the header.
121148
122149 The address is checked for the shape of one before it is used, which is also
123150 why the `looked-up` note on an order can only ever hold an address and not a
@@ -165,6 +192,7 @@ request made at all. No test in this repository talks to the network.
165192 | Variable | Meaning |
166193 | --- | --- |
167194 | `FORGE_APP_IDENTITY_KEY` | The key the forge signs `x-forge-identity` with, verified in `src/who.ts`. Unset means no request can be verified: everybody is anonymous and `/admin` is closed to everybody. There is no password and no way around this. |
195 | `FORGE_APP_TRUSTED_HOPS` | How many proxies of ours stand in front of the socket, for the address a rate limit counts. `1` means `x-forwarded-for`'s right-hand entry is the client. Unset or `0`, the header is not read at all and a bucket is the whole shop's. |
168196 | `APP_SECRET` | Signs cart cookies, sessions, CSRF tokens and order links. Generated into `FORGE_APP_DATA/secret` when unset. |
169197 | `STRIPE_SECRET_KEY` | A Stripe **test** key turns on real Stripe Checkout. |
170198 | `STRIPE_WEBHOOK_SECRET` | Verifies `POST /webhooks/stripe`. |
@@ -198,9 +226,24 @@ from `/search?q=beans` comes back to the results — with one exception: an
198226 order's `token` is stripped, because it opens that order to whoever holds it
199227 and the sign-in path is handed to the forge. Those links also carry
200228 `rel="noreferrer"`: `/__forge/*` shares this origin, so the `Referer` would
201 otherwise hand over the very URL `to` was careful to clean. The terminal takes
202 the same three roles from the environment and keeps stock moves and refunds
203 for an admin.
229 otherwise hand over the very URL `to` was careful to clean.
230
231 The terminal takes the same three roles from the environment. Stock and the
232 tax table read for anybody the forge names; orders,
233 the outbox, the codes and the cards do not, because a receipt carries a gift
234 card's code in full, an order carries a name and an address, and the card list
235 is every balance in the shop. Every key that does anything — moving stock,
236 refunding, writing a code, issuing or voiding a card, setting a rate — is an
237 admin's, and so are the two keys that only read something out: `c`, which
238 prints a balance, and `h`, which prints the stock ledger. `src/desk.ts` holds
239 one table for both, and the tab bar and the foot of the screen are built from
240 it, so nothing can be offered without being gated.
241
242 Rate limits count the address the socket came from, never the first entry of
243 `x-forwarded-for` — see [What a rate limit is keyed on](#what-a-rate-limit-is-keyed-on)
244 for why, and for the one declaration that changes it. Reading a gift card's
245 balance gets a bucket of its own there, separate from typing a code at the
246 till: someone reading their card over and over must still be able to spend it.
204247
205248 ## Shape
206249
@@ -213,18 +256,35 @@ for an admin.
213256 | `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 |
214257 | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is |
215258 | `src/who.ts` `src/session.ts` | Who the forge says is asking; the session that carries the CSRF token |
259 | `src/desk.ts` | The terminal's typed commands: a line of text into a code, a card or a rate |
216260 | `src/peer.ts` `src/ratelimit.ts` | Where a request came from, as far as it can be proved; the buckets keyed on it |
217261 | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox |
218262 | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` |
219263 | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents |
220 | `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 |
264 | `src/units.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.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 |
221265
222266 ## The terminal
223267
224268 The same shop over the same data. The exact command is in `FORGE_APP_SSH`
225269 when the app runs; the footer of every page prints it verbatim, and says the
226 terminal is off when it is unset. `1/2/3` switch between stock, orders and the
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.
270 terminal is off when it is unset. `1`–`6` switch between stock, orders, the
271 outbox, discount codes, gift cards and the tax table — everything `/admin`
272 can see — and `j`/`k` move. A tab whose rows are the shop's own business is an
273 admin's to open: a non-admin gets stock and tax, the other four are marked
274 with a `·` in the tab bar and answer "That is an admin's to do." without
275 leaving the tab they are on:
276
277 | Tab | Keys |
278 | --- | --- |
279 | stock | `s` moves stock with a reason, `h` shows the last of the ledger |
280 | orders | `/` finds an order by number, name or email — the terminal's half of `/orders` — and `r` refunds what is left of one and puts it back on the shelf |
281 | codes | `n` writes a code (`CODE 20% [min 25] [uses 100] [until 2026-12-31]`), `t` turns one on or off, `c` looks a code up |
282 | cards | `i` issues a card (`25 a sorry-for-the-wait card`) and prints its code once, `v` voids or revives one, `c` looks a code up |
283 | tax | `e` sets a rate (`COUNTRY / REGION / 8.75 / what to call it`), `d` sets the rate for anywhere the table does not name, `x` removes one |
284
285 `q` leaves. The first line says who the forge named and what they may read;
286 `FORGE_APP_USER_ROLE` has to be `admin` for any of the keys above, `c` and `h`
287 included — one prints a card's balance and a code's full terms, the other the
288 stock ledger. `ssh` gets you to the screen, not past the door. The parsing,
289 the gates and the refusals are `src/desk.ts`, so they are tested without a
290 pty.
src/admin.ts +2 −2
@@ -316,9 +316,9 @@ export function discountList(ctx: Ctx): Response {
316316 return render(ctx, { title: "Discounts", admin: true }, pages.discounts(ctx.session, discounts.withUse(), messageFrom(ctx)));
317317 }
318318
319 /** A date is a date or it is nothing; a percent is basis points of a whole. */
319 /** A date is a real date or it is nothing; a percent is basis points of a whole. */
320320 const dayOrNull = (value: string): string | null | undefined =>
321 value === "" ? null : /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value} 23:59:59` : undefined;
321 value === "" ? null : discounts.endOfDay(value);
322322
323323 export function discountCreate(ctx: Ctx): Response {
324324 const blocked = guard(ctx);
src/desk.ts added +245
@@ -0,0 +1,245 @@
1 /**
2 * The desk: what someone at the counter does to codes, cards and tax rates,
3 * said as one line of text.
4 *
5 * The terminal has no forms, so every action it offers is a typed line. The
6 * parsing and the refusals live here rather than in `src/tty.ts`, so they can
7 * be tested without a pty — a command is text in and a message out, and the
8 * work itself goes through the same modules the admin pages use.
9 *
10 * Reaching the terminal is not permission to use it. Everything below is what
11 * /admin does — issuing a card prints its code once, and looking a code up
12 * reads out a balance — so the forge has to have named an admin at the
13 * keyboard. `DESK` is the one place that says so, for reading as much as for
14 * doing: the gate in `src/tty.ts` and the foot of its screen both read it, so
15 * a key cannot be offered without being gated or gated without being offered,
16 * and a tab cannot be opened by someone who may not read what is on it.
17 */
18
19 import * as discounts from "./discounts.ts";
20 import * as giftcards from "./giftcards.ts";
21 import * as tax from "./tax.ts";
22 import { formatCents, parseCents } from "./money.ts";
23 import type { Who } from "./who.ts";
24
25 export type Done = { ok: boolean; message: string };
26
27 export const TABS = ["stock", "orders", "outbox", "codes", "cards", "tax"] as const;
28 export type Tab = (typeof TABS)[number];
29
30 /**
31 * Every tab, who may look at it, and the keys it offers — each key with the
32 * words the screen's foot uses.
33 *
34 * `read: "admin"` is not squeamishness about internal pages. An order names a
35 * person and where they live; a receipt in the outbox carries a gift card's
36 * code in full, which is bearer money; the card list is every balance in the
37 * shop and the notes the shop wrote to itself about them; the code list is
38 * every discount that exists, including the ones nobody was told about. That
39 * is the same reason `c` is an admin's key, so it is the same gate.
40 *
41 * Stock and the tax table are the shop's public face — what is on the shelf
42 * and what it is taxed at — so they stay open to whoever the forge named.
43 */
44 const DESK: Record<Tab, { read: "anyone" | "admin"; keys: { key: string; label: string }[] }> = {
45 stock: { read: "anyone", keys: [{ key: "s", label: "s move stock" }, { key: "h", label: "h ledger" }] },
46 orders: {
47 read: "admin",
48 keys: [
49 { key: "/", label: "/ find" },
50 { key: "r", label: "r refund the rest and restock" },
51 ],
52 },
53 outbox: { read: "admin", keys: [] },
54 codes: {
55 read: "admin",
56 keys: [
57 { key: "n", label: "n new code" },
58 { key: "t", label: "t on/off" },
59 { key: "c", label: "c check a code" },
60 ],
61 },
62 cards: {
63 read: "admin",
64 keys: [
65 { key: "i", label: "i issue a card" },
66 { key: "v", label: "v void/revive" },
67 { key: "c", label: "c check a code" },
68 ],
69 },
70 tax: {
71 read: "anyone",
72 keys: [
73 { key: "e", label: "e set a rate" },
74 { key: "d", label: "d default rate" },
75 { key: "x", label: "x remove" },
76 ],
77 },
78 };
79
80 /** True when this key on this tab is the desk's work, and so an admin's. */
81 export const needsAdmin = (tab: Tab, key: string): boolean => DESK[tab].keys.some((offer) => offer.key === key);
82
83 /** True when what is printed on this tab is an admin's to read. */
84 export const needsAdminToRead = (tab: Tab): boolean => DESK[tab].read === "admin";
85
86 /** The tabs this person may open. A non-admin gets the ones that are not private. */
87 export const visible = (who: Who): Tab[] =>
88 TABS.filter((tab) => who.role === "admin" || !needsAdminToRead(tab));
89
90 /** What this person may do on this tab. Nothing, unless the forge named an admin. */
91 export const offered = (tab: Tab, who: Who): string[] =>
92 who.role === "admin" ? DESK[tab].keys.map((offer) => offer.label) : [];
93
94 const no = (message: string): Done => ({ ok: false, message });
95 const yes = (message: string): Done => ({ ok: true, message });
96
97 const words = (line: string): string[] => line.trim().split(/\s+/).filter((word) => word !== "");
98
99 /** "20%" is a percentage, "free" is free shipping, "5" and "$5.00" are money. */
100 function readValue(text: string): { kind: "percent" | "fixed" | "free_shipping"; value: number } | undefined {
outdated

I bought /p/gift-card on the preview, paid the mock gateway, took GC-····M8RG off the receipt and typed it at /gift-cards: balance, issue amount and the ledger row came back in 149 ms, the code was not echoed, the lookup is a POST with a CSRF token (a POST without one is 403), and an unknown code is refused in the cart's own words. The redeem fix is the best part of this pull — putting the window into the same conditional UPDATE that takes the use is the right place for it, and the test that would fail without the window condition proves it. Two things need fixing before this lands.

1. High — the branch predates the identity change, and its new terminal actions have no role check. This is based on 5991215; main is now 1d0fbed, and the pull does not merge (conflicts in README.md, src/tty.ts, src/units.test.ts). src/tty.ts here imports ensureDemoPassword from src/session.ts and prints /admin password (generated, …) on the first screen — that export is gone from main, so the terminal will not even start after the merge. More important: main's tty takes who is at the keyboard from atKeyboard() and gates s and r on person.role === "admin", and the seven new keys added here — n, t, i, v, e, d, x — are ungated, so a non-admin who reaches the terminal could mint gift cards, write a customer's balance to zero and rewrite the tax table. The README says it outright: "Whoever got through ssh is as privileged as /admin". That is the assumption main deliberately dropped. Fix first: rebase onto main, delete the generated-password lines from src/tty.ts and the README, and put isAdmin in front of every desk action exactly as main does for s and r (if (key === "i" && tab === "cards" && isAdmin), and the same for the rest — c/look prints a discount's full terms and a card's balance, so gate that too; leave tabs 4–6 readable). The desk itself is well shaped: text in, message out, tested without a pty, work done through the same modules the admin pages use. It only needs the door in front of it.

2. Medium — the guessing limit on /gift-cards does not hold in front of a real client. Fourteen POSTs from here with a rotating x-forwarded-for all answered 200; the same fourteen without the header hit 429 with retry-after: 10 after ten. context() in src/server.ts builds ctx.ip from the first entry of a client-supplied header, so the bucket the README and the new test both rely on is keyed on something the guesser chooses. 32^12 of code space means blind guessing is impractical regardless, so this is defence in depth rather than an open till — but this pull is the one that puts a discoverable, footer-linked oracle on a bearer instrument, so it is the pull that should make the limit real: prefer self.requestIP() and only consult x-forwarded-for when the forge's proxy is known to be in front, taking the last hop rather than the first. src/server.ts is untouched here, which is why this is second and not first.

3. Low — the ledger shows the shop's own words to the card holder. entriesOf reasons are printed verbatim, so the reason an admin types when voiding a card (v prompts "Void it — reason:") lands on a page anyone holding the code can read. It is escaped, so this is disclosure and not injection, but "voided" would serve the holder just as well as whatever the admin typed to themselves.

4. Nit — the confirmation page does not say where the code is. /orders/:number shows the paid gift card line with no hint that the only copy of the code is one click away on the receipt; a buyer who closes that tab has nothing. One sentence on the confirmation next to the gift card line would save the support mail.

The balance window is a good idea, the shape is right (a page module, a handler, the existing bucket, no new dependency), and the direction is served: a stranger can still buy the one thing and the money trail still reconciles. Rebase, gate the terminal, and I will look again.

101 const lowered = text.toLowerCase();
102 if (lowered === "free" || lowered === "freeship" || lowered === "shipping")
103 return { kind: "free_shipping", value: 0 };
104 if (text.endsWith("%")) {
105 const points = parseCents(text.slice(0, -1));
106 return points === undefined || points > 10000 ? undefined : { kind: "percent", value: points };
107 }
108 const cents = parseCents(text);
109 return cents === undefined || cents === 0 ? undefined : { kind: "fixed", value: cents };
110 }
111
112 export const NEW_DISCOUNT_SHAPE = "CODE 20% [min 25] [uses 100] [until 2026-12-31]";
113
114 /** "SPRING20 20% min 25 uses 100 until 2026-12-31" — one code, however limited. */
115 export function newDiscount(line: string): Done {
116 const parts = words(line);
117 const code = discounts.normalizeCode(parts[0] ?? "");
118 if (code.length < 3) return no("A code is at least three letters, like SPRING20.");
119 const worth = readValue(parts[1] ?? "");
120 if (!worth) return no(`A discount is 20%, 5.00 or free. Say: ${NEW_DISCOUNT_SHAPE}`);
121
122 let min = 0;
123 let uses: number | null = null;
124 let expires: string | null = null;
125 for (let i = 2; i < parts.length; i += 2) {
126 const word = parts[i]!.toLowerCase();
127 const argument = parts[i + 1] ?? "";
128 if (word === "min") {
129 const cents = parseCents(argument);
130 if (cents === undefined) return no("A minimum is an amount of money, like 25 or 25.00.");
131 min = cents;
132 } else if (word === "uses" || word === "max") {
133 if (!/^\d{1,6}$/.test(argument)) return no("A limit is a whole number of uses, like 100.");
134 uses = Number(argument);
135 } else if (word === "until" || word === "expires") {
136 const day = discounts.endOfDay(argument);
137 if (day === undefined) return no("An expiry is a date like 2026-12-31.");
138 expires = day;
139 } else return no(`I do not know "${word}". Say: ${NEW_DISCOUNT_SHAPE}`);
140 }
141
142 if (discounts.byCode(code)) return no(`${code} already exists.`);
143 try {
144 const id = discounts.create({
145 code,
146 kind: worth.kind,
147 value: worth.value,
148 min_subtotal_cents: min,
149 max_redemptions: uses,
150 starts_at: null,
151 expires_at: expires,
152 });
153 const made = discounts.byId(id)!;
154 return yes(
155 `${made.code} — ${discounts.describe(made)}${min > 0 ? `, over ${formatCents(min)}` : ""}${
156 uses === null ? "" : `, ${uses} use${uses === 1 ? "" : "s"}`
157 }${expires === null ? "" : `, until ${expires.slice(0, 10)}`}.`,
158 );
159 } catch {
160 return no(`${code} could not be created.`);
161 }
162 }
163
164 export function toggleDiscount(id: number): Done {
165 const found = discounts.byId(id);
166 if (!found) return no("No code selected.");
167 discounts.setActive(found.id, found.active !== 1);
168 return yes(`${found.code} is now ${found.active === 1 ? "off" : "on"}.`);
169 }
170
171 /** "25 a sorry-for-the-wait card": an amount, then the reason it exists. */
172 export function issueCard(line: string): Done {
173 const parts = words(line);
174 const cents = parseCents(parts[0] ?? "");
175 if (cents === undefined || cents <= 0) return no("A card is worth an amount of money, like 25 or 25.00.");
176 const issued = giftcards.issue({ cents, note: parts.slice(1).join(" ").slice(0, 120) || "issued at the terminal" });
177 return yes(`${issued.code} — ${formatCents(cents)}. This is the only copy of the code.`);
178 }
179
180 export function toggleCard(id: number, reason: string): Done {
181 const card = giftcards.byId(id);
182 if (!card) return no("No card selected.");
183 const reviving = card.active !== 1;
184 giftcards.setActive(card.id, reviving, reason.trim() || "voided at the terminal");
185 return yes(
186 reviving
187 ? `····${card.last4} can be used again; it holds ${formatCents(giftcards.byId(card.id)!.balance_cents)}.`
188 : `····${card.last4} is void and its ${formatCents(card.balance_cents)} written off.`,
189 );
190 }
191
192 /**
193 * One box for both kinds of code, the same as the cart's: whoever is asking
194 * does not know which of the two they were handed.
195 */
196 export function look(typed: string): Done {
197 const asDiscount = discounts.byCode(typed);
198 if (asDiscount)
199 return yes(
200 `${asDiscount.code} — ${discounts.describe(asDiscount)}${
201 asDiscount.min_subtotal_cents > 0 ? ` over ${formatCents(asDiscount.min_subtotal_cents)}` : ""
202 }, used ${asDiscount.used_count}${asDiscount.max_redemptions === null ? "" : ` of ${asDiscount.max_redemptions}`}${
203 asDiscount.expires_at ? `, until ${asDiscount.expires_at.slice(0, 10)}` : ""
204 }${asDiscount.active === 1 ? "" : " (off)"}.`,
205 );
206 const card = giftcards.byCode(typed);
207 if (card)
208 return yes(
209 `····${card.last4} holds ${formatCents(card.balance_cents)} of ${formatCents(card.initial_cents)}${
210 card.active === 1 ? "" : " (void)"
211 }${card.note ? ` — ${card.note}` : ""}.`,
212 );
213 return no("That is not a code this shop knows.");
214 }
215
216 export const RATE_SHAPE = "COUNTRY / REGION / 8.75 / what to call it";
217
218 /** "UNITED STATES / CA / 8.75 / California sales tax"; an empty region is the country's own. */
219 export function setRate(line: string): Done {
220 const parts = line.split("/").map((part) => part.trim());
221 if (parts.length < 3) return no(`A rate is: ${RATE_SHAPE}`);
222 const country = tax.normalize(parts[0] ?? "");
223 const region = tax.normalize(parts[1] ?? "");
224 const points = parseCents(parts[2] ?? "");
225 if (country === "") return no("A rate needs a country.");
226 if (points === undefined || points > 10000) return no("A rate is a percentage between 0 and 100, like 8.75.");
227 const label = (parts[3] ?? "").slice(0, 60).trim() || `${country}${region ? ` ${region}` : ""} tax`;
228 tax.upsertRate({ country, region, rate_bp: points, label, active: true });
229 return yes(`${country}${region ? ` ${region}` : ""} — ${(points / 100).toFixed(2)}% (${label}).`);
230 }
231
232 export function setDefaultRate(text: string): Done {
233 const points = parseCents(text.trim());
234 if (points === undefined || points > 10000) return no("A rate is a percentage between 0 and 100, like 8.5.");
235 tax.setDefaultRateBp(points);
236 return yes(`Anywhere the table does not name is taxed at ${(points / 100).toFixed(2)}%.`);
237 }
238
239 export function removeRate(id: number, confirmation: string): Done {
240 const rate = tax.rateById(id);
241 if (!rate) return no("No rate selected.");
242 if (!/^y(es)?$/i.test(confirmation.trim())) return no(`${rate.country} ${rate.region} kept.`);
243 tax.removeRate(rate.id);
244 return yes(`${rate.country}${rate.region ? ` ${rate.region}` : ""} removed; it now falls to the default.`);
245 }
src/discounts.ts +28 −4
@@ -49,6 +49,24 @@ export const describe = (discount: Discount): string =>
4949 ? `${formatCents(discount.value)} off`
5050 : "free shipping";
5151
52 /** The shape dates are stored in: "2026-12-31 23:59:59", comparable as text. */
53 export const stampOf = (now: Date): string => now.toISOString().slice(0, 19).replace("T", " ");
54
55 /**
56 * The last second of a day, or undefined when that is not a day.
57 *
58 * Stamps are compared as text, so a date nobody can reach — 2026-99-99, or
59 * the 30th of February — sorts after every real one and makes a code that
60 * never expires. The shape is not enough: the day is parsed and round-tripped,
61 * and only a date that comes back as itself is stored.
62 */
63 export function endOfDay(day: string): string | undefined {
64 if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return undefined;
65 const parsed = new Date(`${day}T00:00:00Z`);
66 if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== day) return undefined;
67 return `${day} 23:59:59`;
68 }
69
5270 /**
5371 * Is this code usable right now, against this much in goods? The answer is
5472 * advice: `redeem` decides, inside the order's transaction.
@@ -57,7 +75,7 @@ export function evaluate(code: string, base_cents: number, now = new Date()): Ch
5775 const discount = byCode(code);
5876 if (!discount) return { ok: false, error: "That code is not one this shop knows." };
5977 if (discount.active !== 1) return { ok: false, error: `${discount.code} is no longer being accepted.` };
60 const stamp = now.toISOString().slice(0, 19).replace("T", " ");
78 const stamp = stampOf(now);
6179 if (discount.starts_at && stamp < discount.starts_at)
6280 return { ok: false, error: `${discount.code} cannot be used yet.` };
6381 if (discount.expires_at && stamp > discount.expires_at)
@@ -82,14 +100,20 @@ export function evaluate(code: string, base_cents: number, now = new Date()): Ch
82100 /**
83101 * Take one use of the code for an order. False when there was none left to
84102 * take — the caller is inside a transaction and turns that into a refusal.
103 *
104 * Every limit the code carries is in this one statement: whether it is on,
105 * whether now is inside its window, and whether a use is left. `evaluate`
106 * said the same thing a moment earlier, but only this decides, so a code that
107 * expired or was switched off in between is not taken anyway.
85108 */
86 export function redeem(discountId: number, orderId: number, amount_cents: number): boolean {
109 export function redeem(discountId: number, orderId: number, amount_cents: number, now = new Date()): boolean {
87110 const changes = db
88111 .query(
89112 `UPDATE discount SET used_count = used_count + 1
90 WHERE id = ?1 AND active = 1 AND (max_redemptions IS NULL OR used_count < max_redemptions)`,
113 WHERE id = ?1 AND active = 1 AND (max_redemptions IS NULL OR used_count < max_redemptions)
114 AND (starts_at IS NULL OR starts_at <= ?2) AND (expires_at IS NULL OR expires_at >= ?2)`,
91115 )
92 .run(discountId).changes;
116 .run(discountId, stampOf(now)).changes;
93117 if (changes !== 1) return false;
94118 db.query(
95119 "INSERT INTO discount_redemption (discount_id, order_id, kind, amount_cents) VALUES (?1, ?2, 'redeemed', ?3)",
src/giftcards.ts +31
@@ -67,6 +67,37 @@ export const entriesOf = (giftCardId: number, limit = 50): Entry[] =>
6767 )
6868 .all(giftCardId, limit);
6969
70 export type PublicEntry = { at: string; delta: number; what: string };
71
72 /**
73 * The card's movements as the person holding the code is told them.
74 *
75 * The `reason` column is where the shop talks to itself: an admin types one
76 * when they void a card, and that note was written to a colleague, not to a
77 * customer. The row's own facts say everything the holder needs — which way
78 * the money went, and whether an order was involved — so the words here are
79 * the shop's, fixed, and the same for every card.
80 */
81 export function publicEntriesOf(giftCardId: number, limit = 50): PublicEntry[] {
82 const first = db
83 .query<{ id: number | null }, [number]>("SELECT MIN(id) AS id FROM gift_card_entry WHERE gift_card_id = ?1")
84 .get(giftCardId);
85 return entriesOf(giftCardId, limit).map((entry) => ({
86 at: entry.at,
87 delta: entry.delta,
88 what:
89 entry.id === first?.id
90 ? "Issued"
91 : entry.delta > 0
92 ? entry.order_id === null
93 ? "Put on the card"
94 : "Put back from an order"
95 : entry.order_id === null
96 ? "Taken off the card"
97 : "Spent on an order",
98 }));
99 }
100
70101 export const entriesForOrder = (orderId: number): (Entry & { last4: string })[] =>
71102 db
72103 .query<Entry & { last4: string }, [number]>(
src/html.ts +2 −2
@@ -90,8 +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.
94 <a href="/orders">Find an order</a></p>
93 — a demonstration store: no goods ship and no money moves.</p>
94 <p><a href="/orders">Find an order</a> · <a href="/gift-cards">Gift cards</a> — buy one, or see what is left on the one you hold.</p>
9595 <p>${
9696 ssh
9797 ? `Same shop in a terminal: <code>${escape(ssh)}</code>`
src/mail.ts +2 −1
@@ -49,7 +49,8 @@ export function receiptText(order: Order, items: OrderItem[], giftCardCodes: str
4949 "",
5050 "Your gift card" + (giftCardCodes.length === 1 ? "" : "s") + " — this is the only copy of the code:",
5151 ...giftCardCodes.map((code) => ` ${code}`),
52 " Type it at the cart to spend it. It never expires.",
52 " Type it at the cart to spend it, or at /gift-cards to see what is",
53 " left on it. It never expires.",
5354 ]
5455 : []),
5556 "",
src/orders.ts +1 −1
@@ -198,7 +198,7 @@ export function place(input: {
198198 if (priced.discount_code !== "") {
199199 const discount = discounts.byCode(priced.discount_code)!;
200200 if (!discounts.redeem(discount.id, created.id, priced.discount_cents))
201 throw new Error(`CODE_GONE:${priced.discount_code} was used up while you were checking out.`);
201 throw new Error(`CODE_GONE:${priced.discount_code} stopped applying while you were checking out.`);
202202 note(created.id, "discount", `${discount.code} — ${discounts.describe(discount)}`);
203203 }
204204 if (priced.gift_card_cents > 0) {
src/pages/cart.ts +3
@@ -59,6 +59,9 @@ export function codeBox(quote: Quote, session: Session, back: "cart" | "checkout
5959 ${quote.discount_code !== "" ? remove("discount", quote.discount_code) : ""}
6060 ${quote.gift_card_last4 !== "" ? remove("gift", `····${quote.gift_card_last4}`) : ""}
6161 </div>
62 <p class="muted" style="margin:.5rem 0 0; font-size:.88rem">
63 Holding a gift card? <a href="/gift-cards">See what is left on it</a>.
64 </p>
6265 </div>`;
6366 }
6467
src/pages/checkout.ts +12
@@ -203,6 +203,9 @@ export function confirmation(
203203 who: Who,
204204 ): string {
205205 const paid = order.status === "paid" || order.status === "refunded" || order.status === "partially_refunded";
206 // A card's code is printed once, on the receipt. Say so here: a buyer who
207 // closes this tab without opening it has bought something they cannot spend.
208 const cards = items.reduce((count, item) => count + (item.gift_card_cents > 0 ? item.quantity : 0), 0);
206209 return `
207210 <h1>${paid ? "Thank you" : "Order " + escape(order.number)}</h1>
208211 <p class="lede">Order ${escape(order.number)} · ${statusPill(order.status)} · placed ${escape(order.created_at)} UTC</p>
@@ -219,6 +222,15 @@ ${
219222 — this store has no mail server, so receipts are kept here instead of sent.</p>`
220223 : ""
221224 }
225 ${
226 paid && cards > 0
227 ? `<p class="notice">${cards === 1 ? "The gift card's code is" : `The ${cards} gift card codes are`} on the receipt,
228 and that is the only copy. <a href="/orders/${escape(order.number)}/receipt?token=${escape(
229 order.access_token,
230 )}">Open the receipt</a> and keep ${cards === 1 ? "it" : "them"} somewhere safe — whoever has the code can spend it,
231 and <a href="/gift-cards">/gift-cards</a> says what is left on one.</p>`
232 : ""
233 }
222234 ${itemsTable(items)}
223235 ${orderTotals(order, refunded)}
224236 <h2>Shipping to</h2>
src/pages/giftcard.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * The gift card window: what is left on a card, and every movement of it.
3 *
4 * The code is asked for with a POST and never put in a URL — a bearer code
5 * does not belong in a browser's history or in an access log — and the page
6 * shows the card by its last four characters, never the code that was typed.
7 * The ledger is the shop's own words for each movement, not the note an admin
8 * typed to themselves: `giftcards.publicEntriesOf` decides them.
9 */
10
11 import { csrfField, escape } from "../html.ts";
12 import { formatCents } from "../money.ts";
13 import type { PublicEntry } from "../giftcards.ts";
14 import type { GiftCard } from "../db.ts";
15 import type { Session } from "../session.ts";
16
17 /** `shown` is set when the ledger was cut off at that many rows. */
18 export type Found = { card: GiftCard; entries: PublicEntry[]; shown?: number };
19
20 const ledger = (entries: PublicEntry[]): string =>
21 entries.length === 0
22 ? ""
23 : `<table>
24 <thead><tr><th>When</th><th>What happened</th><th class="num">Amount</th></tr></thead>
25 <tbody>${entries
26 .map(
27 (entry) => `<tr>
28 <td class="muted">${escape(entry.at)}</td>
29 <td>${escape(entry.what)}</td>
30 <td class="num price">${entry.delta > 0 ? "+" : ""}${escape(formatCents(entry.delta))}</td>
31 </tr>`,
32 )
33 .join("\n")}</tbody>
34 </table>`;
35
36 export function page(session: Session, found?: Found, error?: string): string {
37 return `
38 <h1>Gift cards</h1>
39 <p class="lede">A gift card is money already paid for. Type its code to see what is left on it, or apply it in your cart to spend it. <a href="/p/gift-card">Buy one</a> and its code arrives on the receipt.</p>
40 ${error ? `<p class="notice bad">${escape(error)}</p>` : ""}
41 <form method="post" action="/gift-cards" class="row-actions" style="margin:1rem 0">
42 ${csrfField(session)}
43 <input name="code" maxlength="40" placeholder="GC-XXXX-XXXX-XXXX" aria-label="Gift card code"
44 style="max-width:16rem" autocapitalize="characters" autocomplete="off" spellcheck="false">
45 <button class="btn">Check the balance</button>
46 </form>
47 ${
48 found
49 ? `<div class="totals">
50 <div class="row"><span>Card <span class="muted">····${escape(found.card.last4)}</span></span><span class="price">${escape(
51 formatCents(found.card.initial_cents),
52 )} issued</span></div>
53 <div class="row grand"><span>${found.card.active === 1 ? "On the card now" : "Void — nothing can be spent"}</span><span class="price">${escape(
54 formatCents(found.card.balance_cents),
55 )}</span></div>
56 </div>
57 ${ledger(found.entries)}
58 ${
59 found.shown
60 ? `<p class="muted">The last ${found.shown} movements. Older ones are not shown, so these rows may not add up to the balance above.</p>`
61 : ""
62 }
63 ${
64 found.card.active === 1 && found.card.balance_cents > 0
65 ? `<p><a class="btn" href="/cart">Spend it in your cart</a></p>`
66 : ""
67 }`
68 : ""
69 }
70 <p class="muted">Cards do not expire. Keep the code: it is the only copy, and whoever has it can spend it.</p>
71 `;
72 }
src/peer.ts +43 −12
@@ -6,15 +6,22 @@
66 * socket actually arrived on, which is the one thing here that is not the
77 * caller's to write.
88 *
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.
9 * `X-Forwarded-For` is not read on the strength of its own existence. It would
10 * be the useful thing — it names the shopper behind a proxy — but only if a
11 * proxy we trust appends to it, and a hint nobody vouches for is the caller's
12 * own word: a limit keyed on the caller's own word is not a limit, which is
13 * what the leftmost entry made of every bucket here before. So the default is
14 * that it is dropped, and a bucket behind the forge's proxy is the whole
15 * shop's — see the README.
16 *
17 * The one thing that can vouch for it is the deployment, and only by counting:
18 * FORGE_APP_TRUSTED_HOPS says how many proxies of ours stand in front of the
19 * socket. A proxy appends the peer it saw to the right-hand end of the chain,
20 * so with one trusted hop the shopper is the right-hand entry, with two it is
21 * the one before that, and everything further left is a stranger's typing and
22 * never read. Unset — which is how the forge runs today — nothing in the
23 * header is read at all. Nothing is inferred from the shape of an address:
24 * a guess about who is proxying is not a fact about it.
1825 *
1926 * Nothing leaves here unchecked. The address keys a bucket and it is also
2027 * written into `order_event` detail, where an auditor reads it, so it has to
@@ -22,13 +29,20 @@
2229 */
2330
2431 export type Peer = {
25 /** The socket's address. The caller cannot choose this one. */
32 /** The socket's address, or the shopper a declared proxy vouched for. */
2633 address: string;
2734 };
2835
2936 /** The longest an address can be: an IPv6 one with an IPv4 tail inside it. */
3037 export const MAX = 45;
3138
39 /** How many proxies of ours stand in front of the socket, if we were told. */
40 export function trustedHops(value: string | null | undefined): number | undefined {
41 if (value === undefined || value === null || value.trim() === "") return undefined;
42 const hops = Number(value.trim());
43 return Number.isInteger(hops) && hops >= 0 && hops <= 8 ? hops : undefined;
44 }
45
3246 /**
3347 * The value as an address, or null. Four octets of at most 255, or hex digits
3448 * and colons; everything else — a hostname, a comma, a space, a word an
@@ -43,5 +57,22 @@ export function address(value: string | null | undefined): string | null {
4357 return null;
4458 }
4559
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" });
60 /**
61 * `socket` is the address the request actually arrived on, or nothing yet.
62 * `proxied.hops` is the number the deployment declared: without it the header
63 * is not read, and with it a chain shorter than promised is not the chain we
64 * were promised, so the socket is what is left that cannot be forged.
65 */
66 export function peerOf(
67 socket: string | null | undefined,
68 proxied: { forwardedFor?: string | null; hops?: number } = {},
69 ): Peer {
70 const peer = address(socket) ?? "local";
71 const hops = proxied.hops;
72 if (hops === undefined || hops === 0) return { address: peer };
73 const chain = (proxied.forwardedFor ?? "")
74 .split(",")
75 .map((hop) => address(hop))
76 .filter((hop): hop is string => hop !== null);
77 return { address: chain.length >= hops ? chain[chain.length - hops]! : peer };
78 }
src/proxy.test.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * The app behind a proxy it was told about.
3 *
4 * `src/peer.ts` decides on its own whether `x-forwarded-for` counts, and the
5 * unit tests hold it to that. This file is the wiring: the app is booted in its
6 * own process with FORGE_APP_TRUSTED_HOPS set, because the number is read once
7 * at start, and then asked the only question that matters — does a bucket
8 * follow the shopper the proxy named, and only that one.
9 */
10
11 import { afterAll, beforeAll, expect, test } from "bun:test";
12 import { mkdtempSync } from "node:fs";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15
16 const dataDir = mkdtempSync(join(tmpdir(), "bazaar-proxy-"));
17 const limits = await import("./ratelimit.ts");
18
19 type App = { base: string; stop: () => Promise<void> };
20
21 /** Boot `bun run src/server.ts` and wait for the line that says it is up. */
22 async function boot(env: Record<string, string>): Promise<App> {
23 const proc = Bun.spawn(["bun", "run", "src/server.ts"], {
24 cwd: join(import.meta.dir, ".."),
25 env: { ...process.env, PORT: "0", FORGE_APP_DATA: dataDir, ...env },
26 stdout: "pipe",
27 stderr: "inherit",
28 });
29 const reader = proc.stdout.getReader();
30 const decoder = new TextDecoder();
31 const listening = /listening on (http:\/\/\S+)/;
32 const deadline = Date.now() + 15_000;
33 let log = "";
34 while (!listening.test(log)) {
35 if (Date.now() > deadline) throw new Error(`the app did not start in time:\n${log}`);
36 const { value, done } = await reader.read();
37 if (done) throw new Error(`the app exited before it listened:\n${log}`);
38 log += decoder.decode(value);
39 }
40 void (async () => {
41 try {
42 while (!(await reader.read()).done);
43 } catch {}
44 })();
45 return {
46 base: listening.exec(log)![1]!,
47 stop: async () => {
48 proc.kill();
49 await proc.exited;
50 },
51 };
52 }
53
54 let app: App;
55
56 beforeAll(async () => {
57 app = await boot({ FORGE_APP_TRUSTED_HOPS: "1" });
58 });
59
60 afterAll(async () => {
61 await app?.stop();
62 });
63
64 /** A browser with a cookie jar and a chain it claims to have come through. */
65 class Visitor {
66 jar = new Map<string, string>();
67
68 constructor(private chain: string) {}
69
70 private remember(response: Response): void {
71 for (const line of response.headers.getSetCookie()) {
72 const [pair] = line.split(";");
73 const eq = pair!.indexOf("=");
74 this.jar.set(pair!.slice(0, eq), pair!.slice(eq + 1));
75 }
76 }
77
78 private headers(): Record<string, string> {
79 return {
80 cookie: [...this.jar].map(([name, value]) => `${name}=${value}`).join("; "),
81 "sec-fetch-site": "same-origin",
82 "x-forwarded-for": this.chain,
83 };
84 }
85
86 async token(): Promise<string> {
87 const response = await fetch(app.base + "/orders", { headers: this.headers(), redirect: "manual" });
88 this.remember(response);
89 return /name="csrf" value="([^"]+)"/.exec(await response.text())?.[1] ?? "";
90 }
91
92 /** One guess at somebody's order, which is what the LOOKUP bucket counts. */
93 async guess(csrf: string): Promise<Response> {
94 const body = new URLSearchParams({ number: "BZ-ZZZZZZZZ", email: "[email protected]", csrf });
95 const response = await fetch(app.base + "/orders/find", {
96 method: "POST",
97 body,
98 headers: { ...this.headers(), "content-type": "application/x-www-form-urlencoded" },
99 redirect: "manual",
100 });
101 this.remember(response);
102 return response;
103 }
104 }
105
106 /** Spend a visitor's bucket, and say what the last answer was. */
107 async function spend(visitor: Visitor): Promise<Response> {
108 const csrf = await visitor.token();
109 let last = new Response();
110 for (let attempt = 0; attempt < limits.LOOKUP.capacity + 2; attempt++) last = await visitor.guess(csrf);
111 return last;
112 }
113
114 test("with one hop declared, the shopper the proxy named gets the bucket", async () => {
115 // The proxy appends what it saw on the right, so the right-hand entry is the
116 // shopper and everything left of it is the shopper's own typing.
117 const guesser = new Visitor("10.9.9.9, 203.0.113.7");
118 expect((await spend(guesser)).status).toBe(429);
119
120 // Left-hand entries are the caller's to invent, and inventing them buys
121 // nothing: the same right-hand entry is the same bucket.
122 const sameShopper = new Visitor("198.51.100.44, 203.0.113.7");
123 expect((await sameShopper.guess(await sameShopper.token())).status).toBe(429);
124
125 // Another shopper behind the same proxy is another bucket, so one guesser
126 // does not close the lookup form for the shop.
127 const neighbour = new Visitor("10.9.9.9, 203.0.113.8");
128 expect((await neighbour.guess(await neighbour.token())).status).toBe(200);
129 });
src/ratelimit.ts +13 −4
@@ -1,12 +1,13 @@
11 /**
22 * Rate limits. A token bucket per key, in memory: this is one process, and a
33 * 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.
4 * typing a code, reading a balance and finding an order each get their own
5 * bucket so a busy shop cannot lock anyone out of the others.
66 *
77 * A bucket is keyed on the peer's address and nothing else (see src/peer.ts):
88 * 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
9 * forge's proxy that address is the proxy's unless the deployment declared how
10 * many of its own hops stand in front, so assume these are the shop's limits
1011 * rather than each shopper's, and the capacities are set for a shop: enough
1112 * that ordinary trade never meets them, low enough that guessing at a code or
1213 * an order number runs out of tries long before it runs out of guesses.
@@ -19,11 +20,19 @@ const buckets = new Map<string, Bucket>();
1920
2021 export type Limit = { name: string; capacity: number; perSeconds: number };
2122
23 /**
24 * An address is not always one person: an office, a school, or the forge's own
25 * proxy all arrive as one, so a capacity here is a whole shop's minute rather
26 * than one shopper's. That is why these are in the dozens and not the units.
27 */
2228 export const CHECKOUT: Limit = { name: "checkout", capacity: 30, perSeconds: 60 };
2329 export const PAYMENT: Limit = { name: "payment", capacity: 60, perSeconds: 60 };
2430 // Codes are guessable in principle, so trying them is the slowest thing here.
2531 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.
32 // Looking at a balance is its own bucket: someone reading their card over and
33 // over must still be able to spend it at the till.
34 export const BALANCE: Limit = { name: "balance", capacity: 30, perSeconds: 120 };
35 // An order number with an email beside it is guessable too: a few tries, then wait.
2736 export const LOOKUP: Limit = { name: "lookup", capacity: 30, perSeconds: 300 };
2837
2938 /** True when the caller may proceed; false when the bucket is empty. */
src/routes.ts +3
@@ -29,6 +29,9 @@ export const routes: Route[] = [
2929 { method: "POST", pattern: "/cart/add", handler: shop.addToCart },
3030 { method: "POST", pattern: "/cart/update", handler: shop.updateCart },
3131
32 { method: "GET", pattern: "/gift-cards", handler: (ctx) => shop.giftCardPage(ctx) },
33 { method: "POST", pattern: "/gift-cards", handler: shop.giftCardCheck },
34
3235 { method: "GET", pattern: "/checkout", handler: (ctx) => shop.checkoutForm(ctx) },
3336 { method: "POST", pattern: "/checkout", handler: shop.placeOrder },
3437
src/server.test.ts +177
@@ -13,6 +13,7 @@ const { server } = await import("./server.ts");
1313 const { db } = await import("./db.ts");
1414 const limit = await import("./ratelimit.ts");
1515 const giftcards = await import("./giftcards.ts");
16 const discounts = await import("./discounts.ts");
1617 const base = `http://${server.hostname}:${server.port}`;
1718
1819 type Person = { id: string; name: string; role: "user" | "admin"; expiresMs?: number };
@@ -389,6 +390,12 @@ test("a discount, a part-spent gift card and a state's rate become an order that
389390 .get(order.id)!.amount_cents,
390391 ).toBe(570);
391392
393 // The confirmation says where the only copy of the new card's code is.
394 const confirmation = await shopper.page(`/orders/${number}?token=${encodeURIComponent(token)}`);
395 expect(confirmation).toContain("The gift card's code is");
396 expect(confirmation).toContain(`/orders/${number}/receipt`);
397 expect(confirmation).not.toContain("GC-"); // the code itself is on the receipt, not here
398
392399 const receipt = await shopper.page(`/orders/${number}/receipt?token=${encodeURIComponent(token)}`);
393400 expect(receipt).toContain("Discount (WELCOME10)");
394401 expect(receipt).toContain("California sales tax (8.75%)");
@@ -569,6 +576,60 @@ test("checkout is rate limited", async () => {
569576 limit.forget();
570577 });
571578
579 test("the admin form refuses an expiry that is not a date, on the page as at the desk", async () => {
580 const admin = asAdmin();
581 const page = await admin.page("/admin/discounts");
582 const token = Shopper.tokenIn(page);
583 const sent = await admin.post(
584 "/admin/discounts",
585 { code: "NEVEREXP", kind: "percent", value: "10", expires: "2026-99-99" },
586 token,
587 );
588 expect(sent.headers.get("location")).toContain("m=bad-date");
589 expect(discounts.byCode("NEVEREXP")).toBeNull();
590
591 const good = await admin.post(
592 "/admin/discounts",
593 { code: "REALDATE", kind: "percent", value: "10", expires: "2026-12-31" },
594 token,
595 );
596 expect(good.headers.get("location")).toContain("m=created");
597 expect(discounts.byCode("REALDATE")!.expires_at).toBe("2026-12-31 23:59:59");
598 });
599
600 test("the checkout bucket is the whole shop's, and it refills", async () => {
601 limit.forget();
602 const first = new Shopper();
603 const product = await first.page("/p/paper-filters");
604 await first.post("/cart/add", { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, Shopper.tokenIn(product));
605 const token = Shopper.tokenIn(await first.page("/checkout"));
606 let last = new Response();
607 for (let attempt = 0; attempt < limit.CHECKOUT.capacity + 2; attempt++)
608 last = await first.post("/checkout", { idempotency: "stale", email: "x" }, token);
609 expect(last.status).toBe(429);
610 expect(Number(last.headers.get("retry-after"))).toBeGreaterThan(0);
611
612 // Everyone behind one address arrives on the same socket, and a bucket is
613 // keyed on that and nothing else, so an emptied till is emptied for the next
614 // shopper too. That is the trade the capacity is set for: thirty checkouts a
615 // minute is a shop's minute, which ordinary trade never spends.
616 const second = new Shopper();
617 const theirs = await second.page("/p/paper-filters");
618 await second.post("/cart/add", { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, Shopper.tokenIn(theirs));
619 const checkout = await second.page("/checkout");
620 expect((await second.post("/checkout", { idempotency: "stale", email: "x" }, Shopper.tokenIn(checkout))).status).toBe(
621 429,
622 );
623
624 // A limit that never lets go is an outage; once the bucket refills the till
625 // takes the same browser's order again.
626 limit.forget();
627 const tried = await second.post("/checkout", { idempotency: "stale", email: "x" }, Shopper.tokenIn(checkout));
628 expect(tried.status).toBe(200);
629 expect(await tried.text()).toContain("went stale");
630 limit.forget();
631 });
632
572633 test("an unsigned webhook is refused", async () => {
573634 const response = await fetch(base + "/webhooks/stripe", {
574635 method: "POST",
@@ -755,6 +816,122 @@ test("an admin adds a product and a variant, and the shop sells it", async () =>
755816 expect(stockOf("KIT-SPOON-1")).toBe(5);
756817 });
757818
819 test("whoever holds a gift card's code can see what is left on it, and nobody else learns anything", async () => {
820 limit.forget();
821 const { card, code } = giftcards.issue({ cents: 2500, note: "a present" });
822 expect(giftcards.spend(card.id, 1000, "spent on something else")).toBe(true);
823
824 const holder = new Shopper();
825 const form = await holder.page("/gift-cards");
826 expect(form).toContain("Gift cards");
827 expect(form).not.toContain(code); // nothing is known until the code is typed
828 expect(await holder.page("/")).toContain('href="/gift-cards"'); // reachable without a cart
829
830 const checked = await holder.post("/gift-cards", { code: code.toLowerCase() }, Shopper.tokenIn(form));
831 expect(checked.status).toBe(200);
832 const page = await checked.text();
833 expect(page).toContain(`····${card.last4}`);
834 expect(page).toContain("$15.00"); // what is left
835 expect(page).toContain("$25.00 issued");
836 expect(page).toContain("-$10.00");
837 // The movements are the shop's own words, not the note it wrote to itself.
838 expect(page).toContain("Issued");
839 expect(page).toContain("Taken off the card");
840 expect(page).not.toContain("spent on something else");
841 expect(page).not.toContain(code); // the code itself is never echoed back
842 // The code was posted, so it is not in a URL anyone logs.
843 expect(checked.headers.get("location")).toBeNull();
844
845 // A code the shop does not know is refused in the cart's own words.
846 const stranger = await holder.post("/gift-cards", { code: "GC-ZZZZ-ZZZZ-ZZZZ" }, Shopper.tokenIn(form));
847 const refusal = await stranger.text();
848 expect(refusal).toContain("not one this shop knows");
849 expect(refusal).not.toContain("issued");
850
851 // Guessing codes is the slowest thing in the shop.
852 let last = new Response();
853 for (let attempt = 0; attempt < limit.BALANCE.capacity + 2; attempt++)
854 last = await holder.post("/gift-cards", { code: `GC-AAAA-BBBB-${attempt}` }, Shopper.tokenIn(form));
855 expect(last.status).toBe(429);
856 expect(Number(last.headers.get("retry-after"))).toBeGreaterThan(0);
857 limit.forget();
858 });
859
860 test("a guesser cannot buy a fresh rate limit with a header they typed themselves", async () => {
861 limit.forget();
862 const holder = new Shopper();
863 const form = await holder.page("/gift-cards");
864 const token = Shopper.tokenIn(form);
865
866 // Every one of these claims to come from somewhere else. The bucket counts
867 // the socket, which is the same socket every time, so the limit still bites.
868 let last = new Response();
869 for (let attempt = 0; attempt < limit.BALANCE.capacity + 4; attempt++)
870 last = await holder.post(
871 "/gift-cards",
872 { code: `GC-AAAA-CCCC-${attempt}` },
873 token,
874 { "x-forwarded-for": `198.51.100.${attempt}, 203.0.113.${attempt}` },
875 );
876 expect(last.status).toBe(429);
877 expect(Number(last.headers.get("retry-after"))).toBeGreaterThan(0);
878 limit.forget();
879 });
880
881 test("reading a balance does not use up the tokens for spending a code", async () => {
882 limit.forget();
883 const holder = new Shopper();
884 const form = await holder.page("/gift-cards");
885 const token = Shopper.tokenIn(form);
886
887 // Empty the balance window's own bucket.
888 let last = new Response();
889 for (let attempt = 0; attempt < limit.BALANCE.capacity + 1; attempt++)
890 last = await holder.post("/gift-cards", { code: `GC-AAAA-DDDD-${attempt}` }, token);
891 expect(last.status).toBe(429);
892
893 // The till is a different bucket, so the same visitor can still check out.
894 await addToCart(holder, "COF-GUJI-250", 1);
895 const applied = await applyCode(holder, "welcome10");
896 expect(applied.status).toBe(303);
897 expect(applied.headers.get("location")).toContain("m=applied");
898 limit.forget();
899 });
900
901 test("a long ledger says it was cut off, and a void card offers nothing to spend", async () => {
902 limit.forget();
903 const { card, code } = giftcards.issue({ cents: 100, note: "much used" });
904 for (let n = 0; n < 22; n++) expect(giftcards.credit(card.id, 100, `top-up ${n}`)).toBe(true);
905
906 const holder = new Shopper();
907 const form = await holder.page("/gift-cards");
908 const checked = await holder.post("/gift-cards", { code }, Shopper.tokenIn(form));
909 const page = await checked.text();
910 expect(page).toContain("The last 20 movements");
911 expect(page).toContain("Spend it in your cart"); // live card, money on it
912
913 // Voided: the balance is written away, so there is nothing to spend.
914 giftcards.setActive(card.id, false, "lost in the post");
915 const voided = await holder.post("/gift-cards", { code }, Shopper.tokenIn(form)).then((r) => r.text());
916 expect(voided).toContain("Void — nothing can be spent");
917 expect(voided).not.toContain("Spend it in your cart");
918
919 // A live card with nothing left is the same: no link, and no cut-off note.
920 const empty = giftcards.issue({ cents: 500, note: "spent out" });
921 expect(giftcards.spend(empty.card.id, 500, "spent at another till")).toBe(true);
922 const flat = await holder.post("/gift-cards", { code: empty.code }, Shopper.tokenIn(form)).then((r) => r.text());
923 expect(flat).not.toContain("Spend it in your cart");
924 expect(flat).not.toContain("The last 20 movements");
925
926 // Exactly as many movements as the window holds is not a cut-off: issuing
927 // wrote the first row, so nineteen more make twenty and nothing is hidden.
928 const exact = giftcards.issue({ cents: 100, note: "twenty on the nose" });
929 for (let n = 0; n < 19; n++) expect(giftcards.credit(exact.card.id, 100, `top-up ${n}`)).toBe(true);
930 const full = await holder.post("/gift-cards", { code: exact.code }, Shopper.tokenIn(form)).then((r) => r.text());
931 expect(full).toContain("$20.00"); // twenty rows of a dollar, all of them shown
932 expect(full).not.toContain("The last 20 movements");
933 limit.forget();
934 });
758935 test("an order is found again with its number and the email it was placed with", async () => {
759936 limit.forget();
760937 const shopper = new Shopper();
src/server.ts +13 −3
@@ -25,7 +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";
28 import { peerOf, trustedHops } from "./peer.ts";
2929 import { seed } from "./catalog.ts";
3030 import { seed as seedDiscounts } from "./discounts.ts";
3131 import { seed as seedTax } from "./tax.ts";
@@ -65,6 +65,12 @@ const SECURITY: Record<string, string> = {
6565 // (Sec-Fetch-Site, or Origin); a request that names another site is refused.
6666 // The app's public address is what the forge told us, never the socket's.
6767 const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
68
69 // Whether `x-forwarded-for` may be read at all is the deployment's to declare,
70 // not ours to guess: FORGE_APP_TRUSTED_HOPS is how many proxies of ours stand
71 // in front of the socket. Unset — which is how the forge runs today — the
72 // header is a stranger's typing and src/peer.ts does not read it.
73 const hops = trustedHops(process.env.FORGE_APP_TRUSTED_HOPS);
6874 const sameOrigin = (request: Request): boolean => {
6975 const site = request.headers.get("sec-fetch-site");
7076 if (site !== null) return site === "same-origin" || site === "none";
@@ -176,8 +182,12 @@ function context(
176182 cookies,
177183 setCookie: (value: string) => outgoing.push(value),
178184 secure: publicOrigin.startsWith("https:"),
179 // The socket, never a header: see src/peer.ts for why nothing else counts.
180 peer: peerOf(self.requestIP(request)?.address),
185 // The socket, unless the deployment declared the proxies in front of it:
186 // see src/peer.ts for why nothing a caller writes counts on its own.
187 peer: peerOf(self.requestIP(request)?.address, {
188 forwardedFor: request.headers.get("x-forwarded-for"),
189 hops,
190 }),
181191 };
182192 }
183193
src/shop.ts +31
@@ -15,6 +15,7 @@ import * as giftcards from "./giftcards.ts";
1515 import * as limit from "./ratelimit.ts";
1616 import * as mine from "./mine.ts";
1717 import * as orders from "./orders.ts";
18 import * as giftCardPages from "./pages/giftcard.ts";
1819 import * as pages from "./pages/checkout.ts";
1920 import * as storefront from "./pages/storefront.ts";
2021 import {
@@ -190,6 +191,36 @@ export function applyCode(ctx: Ctx): Response {
190191 const codeRefused = (ctx: Ctx, back: string, error: string): Response =>
191192 back === "/checkout" ? checkoutForm(ctx, error) : cartView(ctx, error);
192193
194 /**
195 * What is left on a gift card, for whoever holds the code. It is asked for
196 * with a POST so the code stays out of the URL, it is rate limited like any
197 * other code, and an unknown code is refused in the same words the cart uses
198 * — this page is not a way to find out which codes exist.
199 */
200 export function giftCardPage(ctx: Ctx, found?: giftCardPages.Found, error?: string): Response {
201 return render(ctx, { title: "Gift cards" }, giftCardPages.page(ctx.session, found, error));
202 }
203
204 /** How many movements the window shows; the page says so when it is full. */
205 const LEDGER_ROWS = 20;
206
207 export function giftCardCheck(ctx: Ctx): Response {
208 if (!limit.take(limit.BALANCE, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.BALANCE, ctx.peer), "balance");
209 const typed = field(ctx.form, "code", 40);
210 if (typed === "") return giftCardPage(ctx, undefined, "Type the code from your receipt.");
211 const card = giftcards.byCode(typed);
212 if (!card) return giftCardPage(ctx, undefined, "That gift card is not one this shop knows.");
213 // One row more than the window is asked for, so "older ones are not shown"
214 // is said when an older one exists and not when the card has exactly enough.
215 const entries = giftcards.publicEntriesOf(card.id, LEDGER_ROWS + 1);
216 const more = entries.length > LEDGER_ROWS;
217 return giftCardPage(ctx, {
218 card,
219 entries: more ? entries.slice(0, LEDGER_ROWS) : entries,
220 shown: more ? LEDGER_ROWS : undefined,
221 });
222 }
223
193224 export function removeCode(ctx: Ctx): Response {
194225 const which = field(ctx.form, "which", 10);
195226 const back = field(ctx.form, "back", 20) === "checkout" ? "/checkout" : "/cart";
src/tty.ts +149 −40
@@ -4,15 +4,26 @@
44 * use — the same catalog, the same orders, the same ledger.
55 *
66 * Who is at the keyboard comes from the forge too: FORGE_APP_USER,
7 * FORGE_APP_USER_ID and FORGE_APP_USER_ROLE, named on the first screen. Moving
8 * stock and refunding are an admin's to do, here as on the web.
7 * FORGE_APP_USER_ID and FORGE_APP_USER_ROLE, named on the first screen.
8 * Reaching this screen is not the same as being allowed to use it: every key
9 * that changes something — and c, which reads a code out — is an admin's,
10 * here as on the web, and so is every tab whose rows are the shop's own
11 * business. Orders, the outbox, the codes and the cards are all of those:
12 * a receipt carries a gift card's code in full, and that code is money to
13 * whoever reads it. `src/desk.ts` holds the one table both gates read.
914 *
10 * 1/2/3 switch between stock, orders and the outbox. j/k move, s moves stock
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.
15 * 1..6 switch between stock, orders, the outbox, discount codes, gift cards
16 * and the tax table — everything /admin can see. j/k move, and each tab says
17 * at its foot what it can do: s moves stock with a reason, / finds an order by
18 * number, name or email, r refunds what is left of an order and puts it back
19 * on the shelf, n writes a code, i issues a card, e sets a tax rate, c looks a
20 * code up. q leaves. Plain ANSI, no dependencies.
1421 */
1522
23 import * as desk from "./desk.ts";
24 import * as discounts from "./discounts.ts";
25 import * as giftcards from "./giftcards.ts";
26 import * as tax from "./tax.ts";
1627 import { db } from "./db.ts";
1728 import { chosen } from "./gateway.ts";
1829 import { formatCents } from "./money.ts";
@@ -20,16 +31,16 @@ import { history, addStock, setStock } from "./inventory.ts";
2031 import { itemsOf, paymentsOf, recent, refund, refundableCents, refundedCents } from "./orders.ts";
2132 import { outbox } from "./mail.ts";
2233 import { atKeyboard } from "./who.ts";
23 import type { Order } from "./db.ts";
34 import type { GiftCard, Order, TaxRate } from "./db.ts";
2435
2536 const person = atKeyboard();
2637 const isAdmin = person.role === "admin";
38 const readable = desk.visible(person).join(", ");
39 const onlyReads = ` — ${readable} read, nothing here changes anything`;
2740 const whoLine =
2841 person.role === "anon"
29 ? "the forge did not name you (anonymous) — stock moves and refunds are closed"
30 : `signed in as ${person.name} (${person.id}) · ${person.role}${
31 isAdmin ? "" : " — stock moves and refunds are for an admin"
32 }`;
42 ? `the forge did not name you (anonymous)${onlyReads}`
43 : `signed in as ${person.name} (${person.id}) · ${person.role}${isAdmin ? "" : onlyReads}`;
3344
3445 const where =
3546 process.env.FORGE_APP_ENV === "preview"
@@ -53,7 +64,10 @@ const stockRows = (): Row[] =>
5364 )
5465 .all();
5566
56 let tab: "stock" | "orders" | "outbox" = "stock";
67 const TABS = desk.TABS;
68 type Tab = desk.Tab;
69
70 let tab: Tab = "stock";
5771 let cursor = 0;
5872 let prompt: { label: string; text: string; run: (value: string) => string } | undefined;
5973 let message = "";
@@ -61,9 +75,51 @@ let message = "";
6175 // the same needle /admin/orders takes.
6276 let filter = "";
6377
78 // What the orders tab is showing: every order, or the ones `/` narrowed to.
6479 const orderRows = (): Order[] => recent(100, filter);
6580
66 const rowsFor = (): unknown[] => (tab === "stock" ? stockRows() : tab === "orders" ? orderRows() : outbox(50));
81 const rowsFor = (): unknown[] =>
82 tab === "stock"
83 ? stockRows()
84 : tab === "orders"
85 ? orderRows()
86 : tab === "outbox"
87 ? outbox(50)
88 : tab === "codes"
89 ? discounts.withUse()
90 : tab === "cards"
91 ? giftcards.all()
92 : tax.rates();
93
94 /** A message from the desk, with a refusal marked so it cannot be missed. */
95 const say = (done: desk.Done): string => (done.ok ? done.message : `${esc}33m${done.message}${esc}0m`);
96
97 /** The refusal the desk never gets to give, because a non-admin never reaches it. */
98 const notYours = say({ ok: false, message: "That is an admin's to do." });
99
100 /**
101 * The foot of the screen. A key this person may not press is not offered:
102 * `desk.offered` is the same list `desk.needsAdmin` gates on, h included.
103 */
104 const hints = (): string =>
105 [
106 "1-6 tabs",
107 "j/k move",
108 ...desk.offered(tab, person),
109 ...(tab === "orders" && filter !== "" ? [`showing “${filter}”`] : []),
110 "q quit",
111 ].join(" · ");
112
113 /** The tab bar. A tab this person may not read is named but marked closed. */
114 const tabBar = (): string =>
115 TABS.map((name, i) => {
116 const closed = !isAdmin && desk.needsAdminToRead(name);
117 const text = `${i + 1} ${name}${closed ? "·" : ""}`;
118 return name === tab ? bold(text) : dim(text);
119 }).join(" ");
120
121 const windowOf = (row: { starts_at: string | null; expires_at: string | null }): string =>
122 row.expires_at ? `until ${row.expires_at.slice(0, 10)}` : row.starts_at ? `from ${row.starts_at.slice(0, 10)}` : "";
67123
68124 function detail(order: Order): string[] {
69125 const lines = [""];
@@ -85,11 +141,7 @@ function draw(): void {
85141 const lines: string[] = [];
86142 lines.push(`${bold("bazaar")} ${dim(where)} ${dim(chosen().label)}`);
87143 lines.push(dim(whoLine));
88 lines.push(
89 ["stock", "orders", "outbox"]
90 .map((name, i) => (name === tab ? bold(`${i + 1} ${name}`) : dim(`${i + 1} ${name}`)))
91 .join(" "),
92 );
144 lines.push(tabBar());
93145 lines.push("");
94146 if (rows.length === 0) lines.push(dim("Nothing here yet."));
95147 if (tab === "stock")
@@ -116,19 +168,42 @@ function draw(): void {
116168 lines.push(`${mark} ${i === cursor ? bold(email.subject) : email.subject} ${dim(email.to_address)}`);
117169 if (i === cursor) lines.push("", ...email.body.split("\n").map((line) => ` ${dim(line)}`));
118170 });
171 if (tab === "codes")
172 (rows as discounts.DiscountStats[]).forEach((code, i) => {
173 const mark = i === cursor ? "▸" : " ";
174 const used = `${code.redeemed - code.released}${code.max_redemptions === null ? "" : `/${code.max_redemptions}`}`;
175 const text = `${code.code.padEnd(18)} ${discounts.describe(code).padEnd(16)} ${(code.min_subtotal_cents > 0 ? `over ${formatCents(code.min_subtotal_cents)}` : "").padEnd(14)} ${used.padStart(
176 7,
177 )} used ${formatCents(code.given_cents).padStart(10)} given ${code.active === 1 ? "" : " off"}`;
178 lines.push(`${mark} ${i === cursor ? bold(text) : text}${windowOf(code) ? dim(` ${windowOf(code)}`) : ""}`);
179 });
180 if (tab === "cards")
181 (rows as GiftCard[]).forEach((card, i) => {
182 const mark = i === cursor ? "▸" : " ";
183 const text = `····${card.last4.padEnd(8)} ${formatCents(card.balance_cents).padStart(10)} of ${formatCents(
184 card.initial_cents,
185 ).padStart(10)} ${card.active === 1 ? " " : " void"} ${card.note.slice(0, 34)}`;
186 lines.push(`${mark} ${i === cursor ? bold(text) : text}`);
187 if (i === cursor)
188 lines.push(
189 ...giftcards
190 .entriesOf(card.id, 6)
191 .map((entry) => ` ${dim(`${entry.at} ${entry.delta > 0 ? "+" : ""}${formatCents(entry.delta)} ${entry.reason}`)}`),
192 );
193 });
194 if (tab === "tax") {
195 lines.push(dim(` anywhere else ${(tax.defaultRateBp() / 100).toFixed(2)}% (d to change)`), "");
196 (rows as TaxRate[]).forEach((rate, i) => {
197 const mark = i === cursor ? "▸" : " ";
198 const text = `${rate.country.padEnd(20)} ${(rate.region || "—").padEnd(6)} ${((rate.rate_bp / 100).toFixed(2) + "%").padStart(
199 7,
200 )} ${rate.label.slice(0, 30)}`;
201 lines.push(`${mark} ${i === cursor ? bold(text) : text}`);
202 });
203 }
119204 lines.push("");
120205 if (message) lines.push(message, "");
121 lines.push(
122 prompt === undefined
123 ? dim(
124 tab === "stock"
125 ? `1/2/3 tabs · j/k move${isAdmin ? " · s move stock" : ""} · h ledger · q quit`
126 : tab === "orders"
127 ? `1/2/3 tabs · j/k move · / find${filter ? ` (“${filter}”)` : ""}${isAdmin ? " · r refund the rest and restock" : ""} · q quit`
128 : "1/2/3 tabs · j/k move · q quit",
129 )
130 : `${prompt.label} ${prompt.text}${esc}7m ${esc}0m`,
131 );
206 lines.push(prompt === undefined ? dim(hints()) : `${prompt.label} ${prompt.text}${esc}7m ${esc}0m`);
132207 out(`${esc}2J${esc}H` + lines.join("\r\n"));
133208 }
134209
@@ -196,18 +271,52 @@ process.stdin.on("data", (key: string) => {
196271 const count = rowsFor().length;
197272 if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1));
198273 if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
199 if (key === "1") ((tab = "stock"), (cursor = 0));
200 if (key === "2") ((tab = "orders"), (cursor = 0));
201 if (key === "3") ((tab = "outbox"), (cursor = 0));
202 if (key === "s" && tab === "stock")
203 if (isAdmin) prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock };
204 else message = "Moving stock is for an admin.";
205 if (key === "/" && tab === "orders")
274 // A tab whose rows are the shop's own business is not opened at all for a
275 // non-admin: the screen stays where it was and says why, so nothing private
276 // is ever drawn.
277 const asTab = TABS[Number(key) - 1];
278 if (asTab && !isAdmin && desk.needsAdminToRead(asTab)) message = notYours;
279 else if (asTab) ((tab = asTab), (cursor = 0));
280 // Every key from here down either changes something or reads a code out, so
281 // every one of them is an admin's. A non-admin gets one refusal; the desk
282 // below is never reached.
283 if (!isAdmin && desk.needsAdmin(tab, key)) message = notYours;
284 if (key === "s" && tab === "stock" && isAdmin)
285 prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock };
286 if (key === "/" && tab === "orders" && isAdmin)
206287 prompt = { label: "Find an order (number, name or email):", text: filter, run: findOrders };
207 if (key === "r" && tab === "orders")
208 if (isAdmin) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest };
209 else message = "Refunding is for an admin.";
210 if (key === "h" && tab === "stock")
288 if (key === "r" && tab === "orders" && isAdmin) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest };
289 if (key === "c" && (tab === "codes" || tab === "cards") && isAdmin)
290 prompt = { label: "Check a code:", text: "", run: (value) => say(desk.look(value)) };
291 if (key === "n" && tab === "codes" && isAdmin)
292 prompt = { label: `New code (${desk.NEW_DISCOUNT_SHAPE}):`, text: "", run: (value) => say(desk.newDiscount(value)) };
293 if (key === "t" && tab === "codes" && isAdmin) {
294 const code = discounts.withUse()[cursor];
295 message = say(code ? desk.toggleDiscount(code.id) : { ok: false, message: "No code selected." });
296 }
297 if (key === "i" && tab === "cards" && isAdmin)
298 prompt = { label: "Issue a card (e.g. 25 a sorry-for-the-wait card):", text: "", run: (value) => say(desk.issueCard(value)) };
299 if (key === "v" && tab === "cards" && isAdmin) {
300 const card = giftcards.all()[cursor];
301 prompt = {
302 label: card && card.active !== 1 ? "Bring it back — reason:" : "Void it — reason:",
303 text: "",
304 run: (value) => say(card ? desk.toggleCard(card.id, value) : { ok: false, message: "No card selected." }),
305 };
306 }
307 if (key === "e" && tab === "tax" && isAdmin)
308 prompt = { label: `Set a rate (${desk.RATE_SHAPE}):`, text: "", run: (value) => say(desk.setRate(value)) };
309 if (key === "d" && tab === "tax" && isAdmin)
310 prompt = { label: "Rate anywhere the table does not name (e.g. 8.5):", text: "", run: (value) => say(desk.setDefaultRate(value)) };
311 if (key === "x" && tab === "tax" && isAdmin) {
312 const rate = tax.rates()[cursor];
313 prompt = {
314 label: rate ? `Remove ${rate.country} ${rate.region || "(whole country)"}? y/n:` : "Nothing selected — press enter:",
315 text: "",
316 run: (value) => say(rate ? desk.removeRate(rate.id, value) : { ok: false, message: "No rate selected." }),
317 };
318 }
319 if (key === "h" && tab === "stock" && isAdmin)
211320 message = history(8)
212321 .map((entry) => dim(`${entry.at} ${entry.sku} ${entry.delta > 0 ? "+" : ""}${entry.delta} ${entry.reason}`))
213322 .join("\r\n");
src/units.test.ts +282
@@ -15,6 +15,7 @@ const mine = await import("./mine.ts");
1515 const peer = await import("./peer.ts");
1616 const { escape } = await import("./html.ts");
1717 const catalog = await import("./catalog.ts");
18 const desk = await import("./desk.ts");
1819 const discounts = await import("./discounts.ts");
1920 const giftcards = await import("./giftcards.ts");
2021 const tax = await import("./tax.ts");
@@ -304,3 +305,284 @@ test("the settings the admin password left behind are deleted, and nothing else
304305 expect(getSetting("admin_salt")).toBeUndefined();
305306 expect(getSetting("tax_default_bp")).toBe("800");
306307 });
308
309 /** An order row to hang a redemption on, without going through checkout. */
310 const bareOrder = (number: string): number =>
311 db
312 .query<{ id: number }, [string]>(
313 `INSERT INTO "order" (number, access_token, email, name, address1, city, postal, country,
314 subtotal_cents, shipping_cents, tax_cents, total_cents, idempotency_key)
315 VALUES (?1, 'token', '[email protected]', 'A Buyer', '1 Road', 'Town', '00000', 'UNITED STATES',
316 0, 0, 0, 0, ?1) RETURNING id`,
317 )
318 .get(number)!.id;
319
320 test("a code's limits and expiry are enforced where the use is taken, not only where it is shown", () => {
321 const orderId = bareOrder("BZ-REDEEM-1");
322 const used = (id: number): number => discounts.byId(id)!.used_count;
323 const rows = (id: number): number =>
324 db.query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM discount_redemption WHERE discount_id = ?1").get(id)!.n;
325
326 // Expired at the moment it is taken: evaluate would refuse it too, but this
327 // is the statement that decides, and it refuses without writing anything.
328 const expired = discounts.create({
329 code: "GONE-BY-NOW",
330 kind: "fixed",
331 value: 500,
332 min_subtotal_cents: 0,
333 max_redemptions: null,
334 starts_at: null,
335 expires_at: "2020-01-01 23:59:59",
336 });
337 expect(discounts.redeem(expired, orderId, 500)).toBe(false);
338 expect(used(expired)).toBe(0);
339 expect(rows(expired)).toBe(0);
340
341 // Not yet started is the same refusal from the other side.
342 const early = discounts.create({
343 code: "NOT-YET",
344 kind: "fixed",
345 value: 500,
346 min_subtotal_cents: 0,
347 max_redemptions: null,
348 starts_at: "2099-01-01 00:00:00",
349 expires_at: null,
350 });
351 expect(discounts.redeem(early, orderId, 500)).toBe(false);
352 expect(used(early)).toBe(0);
353
354 // One that is live, with one use: the first takes it and the second cannot.
355 const once = discounts.create({
356 code: "ONE-USE-ONLY",
357 kind: "fixed",
358 value: 500,
359 min_subtotal_cents: 0,
360 max_redemptions: 1,
361 starts_at: null,
362 expires_at: "2099-12-31 23:59:59",
363 });
364 expect(discounts.redeem(once, orderId, 500)).toBe(true);
365 expect(discounts.redeem(once, orderId, 500)).toBe(false);
366 expect(used(once)).toBe(1);
367 expect(rows(once)).toBe(1);
368
369 // The same code, read at a moment after its window closes: still refused.
370 const later = new Date("2100-01-01T00:00:00Z");
371 const roomy = discounts.create({
372 code: "ROOMY",
373 kind: "fixed",
374 value: 500,
375 min_subtotal_cents: 0,
376 max_redemptions: null,
377 starts_at: null,
378 expires_at: "2099-12-31 23:59:59",
379 });
380 expect(discounts.redeem(roomy, orderId, 500, later)).toBe(false);
381 expect(used(roomy)).toBe(0);
382
383 // A use given back is a row of its own, and the counter follows it.
384 discounts.release(once, orderId, 500);
385 expect(used(once)).toBe(0);
386 expect(rows(once)).toBe(2);
387 expect(discounts.withUse().find((row) => row.code === "ONE-USE-ONLY")!.given_cents).toBe(0);
388 });
389
390 test("the terminal's typed commands do what the admin forms do", () => {
391 // A discount, with every limit the form offers, in one line.
392 const made = desk.newDiscount("spring20 20% min 25 uses 100 until 2026-12-31");
393 expect(made.ok).toBe(true);
394 const spring = discounts.byCode("SPRING20")!;
395 expect(spring.kind).toBe("percent");
396 expect(spring.value).toBe(2000);
397 expect(spring.min_subtotal_cents).toBe(2500);
398 expect(spring.max_redemptions).toBe(100);
399 expect(spring.expires_at).toBe("2026-12-31 23:59:59");
400 expect(discounts.worth(spring, 5000)).toBe(1000);
401
402 expect(desk.newDiscount("spring20 20%").ok).toBe(false); // already exists
403 expect(desk.newDiscount("XY 20%").ok).toBe(false); // too short to be a code
404 expect(desk.newDiscount("HALFOFF 120%").ok).toBe(false); // more than the goods
405 expect(desk.newDiscount("NOPE 5.00 min bananas").ok).toBe(false);
406 expect(desk.newDiscount("NOPE 5.00 forever 1").ok).toBe(false); // a word it does not know
407 expect(desk.newDiscount("NOPE 5.00 until soon").ok).toBe(false);
408 // A date nobody can reach sorts after every real stamp, so it would be a
409 // code that never expires. The shape is not enough: the day has to be a day.
410 expect(desk.newDiscount("NOPE 10% until 2026-99-99").ok).toBe(false);
411 expect(desk.newDiscount("NOPE 10% until 2026-02-30").ok).toBe(false);
412 expect(desk.newDiscount("NOPE 10% until 0000-00-00").ok).toBe(false);
413 expect(discounts.byCode("NOPE")).toBeNull(); // a refused line writes nothing
414 expect(desk.newDiscount("POSTFREE free").ok).toBe(true);
415 expect(discounts.byCode("POSTFREE")!.kind).toBe("free_shipping");
416
417 // On and off, the same switch the admin page has.
418 expect(desk.toggleDiscount(spring.id).ok).toBe(true);
419 expect(discounts.byId(spring.id)!.active).toBe(0);
420 expect(discounts.evaluate("SPRING20", 5000).ok).toBe(false);
421 desk.toggleDiscount(spring.id);
422 expect(discounts.byId(spring.id)!.active).toBe(1);
423
424 // A card, issued and then read back by the code it printed once.
425 const issued = desk.issueCard("25 a sorry-for-the-wait card");
426 expect(issued.ok).toBe(true);
427 const code = /GC-[A-Z0-9-]+/.exec(issued.message)![0];
428 expect(giftcards.byCode(code)!.balance_cents).toBe(2500);
429 expect(desk.look(code).message).toContain("$25.00");
430 expect(desk.look("SPRING20").message).toContain("20% off");
431 expect(desk.look("no-such-code-at-all").ok).toBe(false);
432 expect(desk.issueCard("bananas").ok).toBe(false);
433
434 // Voiding writes the balance away and says so; reviving leaves it at zero.
435 const card = giftcards.byCode(code)!;
436 expect(desk.toggleCard(card.id, "lost in the post").ok).toBe(true);
437 expect(giftcards.byId(card.id)!.balance_cents).toBe(0);
438 expect(giftcards.entriesOf(card.id)[0]!.reason).toBe("lost in the post");
439 desk.toggleCard(card.id, "found again");
440 expect(giftcards.byId(card.id)!.active).toBe(1);
441
442 // A tax rate, and the default behind it.
443 expect(desk.setRate("united states / wa / 10.25 / Washington sales tax").ok).toBe(true);
444 expect(tax.rateFor({ country: "United States", region: "WA" })).toEqual({
445 rate_bp: 1025,
446 label: "Washington sales tax",
447 });
448 expect(desk.setRate("nowhere / / 200").ok).toBe(false); // over 100%
449 expect(desk.setRate("one part only").ok).toBe(false);
450 expect(desk.setRate(" / CA / 5").ok).toBe(false); // no country
451 expect(desk.setDefaultRate("bananas").ok).toBe(false);
452 expect(desk.setDefaultRate("9").ok).toBe(true);
453 expect(tax.defaultRateBp()).toBe(900);
454
455 // Removing asks first, and a rate that is gone falls to the default.
456 const washington = tax.rates().find((rate) => rate.region === "WA")!;
457 expect(desk.removeRate(washington.id, "n").ok).toBe(false);
458 expect(tax.rateById(washington.id)).not.toBeNull();
459 expect(desk.removeRate(washington.id, "yes").ok).toBe(true);
460 expect(tax.rateById(washington.id)).toBeNull();
461 expect(tax.rateFor({ country: "United States", region: "WA" }).rate_bp).toBe(850); // the country's own rate
462 desk.setDefaultRate("8.5");
463 });
464
465 test("every key that does the desk's work needs an admin, and is offered to nobody else", () => {
466 const admin = { role: "admin", id: "1", name: "root" } as const;
467 const shopper = { role: "user", id: "7", name: "nora" } as const;
468 const anon = { role: "anon" } as const;
469
470 // The seven keys this pull added, plus the two that were already there.
471 expect(desk.needsAdmin("cards", "i")).toBe(true);
472 expect(desk.needsAdmin("cards", "v")).toBe(true);
473 expect(desk.needsAdmin("cards", "c")).toBe(true); // c reads a balance out
474 expect(desk.needsAdmin("codes", "n")).toBe(true);
475 expect(desk.needsAdmin("codes", "t")).toBe(true);
476 expect(desk.needsAdmin("tax", "e")).toBe(true);
477 expect(desk.needsAdmin("tax", "d")).toBe(true);
478 expect(desk.needsAdmin("tax", "x")).toBe(true);
479 expect(desk.needsAdmin("stock", "s")).toBe(true);
480 expect(desk.needsAdmin("orders", "r")).toBe(true);
481 expect(desk.needsAdmin("stock", "h")).toBe(true); // h reads the stock ledger out
482
483 // Moving and switching tabs are not the desk's, and a key is only the
484 // desk's on the tab it belongs to.
485 for (const tab of desk.TABS) for (const key of ["j", "k", "q", "1", "6"]) expect(desk.needsAdmin(tab, key)).toBe(false);
486 expect(desk.needsAdmin("outbox", "i")).toBe(false);
487 expect(desk.needsAdmin("stock", "n")).toBe(false);
488
489 for (const tab of desk.TABS) {
490 // The foot of the screen and the gate read the same table: everything
491 // offered needs an admin, and everything that needs one is offered.
492 const keys = ["/", ..."abcdefghilmnoprstuvwxyz".split("")];
493 const gated = keys.filter((key) => desk.needsAdmin(tab, key));
494 expect(desk.offered(tab, admin).map((label) => label[0]).sort()).toEqual(gated.sort());
495 // Whoever the forge did not name an admin is offered nothing at all.
496 expect(desk.offered(tab, shopper)).toEqual([]);
497 expect(desk.offered(tab, anon)).toEqual([]);
498 }
499 expect(desk.offered("cards", admin)).toContain("i issue a card");
500 });
501
502 test("the tabs that print a code, a receipt or a person are an admin's to read", () => {
503 const admin = { role: "admin", id: "1", name: "root" } as const;
504 const shopper = { role: "user", id: "7", name: "nora" } as const;
505 const anon = { role: "anon" } as const;
506
507 // A receipt in the outbox carries a gift card's code in full, an order
508 // carries a name and an address, the card list is every balance in the shop
509 // and the code list is every discount that exists — all of it the shop's.
510 expect(desk.needsAdminToRead("orders")).toBe(true);
511 expect(desk.needsAdminToRead("outbox")).toBe(true);
512 expect(desk.needsAdminToRead("cards")).toBe(true);
513 expect(desk.needsAdminToRead("codes")).toBe(true);
514 // What is on the shelf and what it is taxed at is the shop's public face.
515 expect(desk.needsAdminToRead("stock")).toBe(false);
516 expect(desk.needsAdminToRead("tax")).toBe(false);
517
518 expect(desk.visible(admin)).toEqual([...desk.TABS]);
519 expect(desk.visible(shopper)).toEqual(["stock", "tax"]);
520 expect(desk.visible(anon)).toEqual(["stock", "tax"]);
521
522 // Nothing a non-admin can open offers a key they may not press, and the
523 // first tab the terminal opens on is one of them.
524 for (const tab of desk.visible(shopper)) expect(desk.offered(tab, shopper)).toEqual([]);
525 expect(desk.visible(shopper)[0]).toBe(desk.TABS[0]);
526 });
527
528 test("a rate limit counts an address the caller cannot choose", () => {
529 const client = "203.0.113.7"; // a real one, out on the internet
530 const proxy = "10.1.2.3"; // the forge, on its own network
531 const forged = "198.51.100.1"; // what a guesser types to get a fresh bucket
532
533 // Nobody vouched for the header, so it is not read: the socket is the key,
534 // whether the socket is a shopper's or the forge's proxy.
535 expect(peer.peerOf(client, { forwardedFor: forged })).toEqual({ address: client });
536 expect(peer.peerOf(proxy, { forwardedFor: `${forged}, ${client}` })).toEqual({ address: proxy });
537 // Declaring no hops of our own says the same thing out loud.
538 expect(peer.peerOf(client, { forwardedFor: forged, hops: 0 })).toEqual({ address: client });
539 // No socket to stand on is one shared bucket, which is still a limit.
540 expect(peer.peerOf(undefined, { forwardedFor: forged })).toEqual({ address: "local" });
541 });
542
543 test("a deployment that knows how many proxies are in front says so, and is believed exactly", () => {
544 const client = "203.0.113.7";
545 const forged = "198.51.100.1"; // what the caller wrote, at the far left
546 // The proxy's own address can be public, so no range tells us it is ours.
547 const publicProxy = "192.0.2.50";
548
549 expect(peer.trustedHops("1")).toBe(1);
550 expect(peer.trustedHops("2")).toBe(2);
551 expect(peer.trustedHops("0")).toBe(0);
552 expect(peer.trustedHops(undefined)).toBeUndefined();
553 expect(peer.trustedHops("")).toBeUndefined();
554 expect(peer.trustedHops("lots")).toBeUndefined();
555 expect(peer.trustedHops("-1")).toBeUndefined();
556 expect(peer.trustedHops("99")).toBeUndefined(); // nobody has 99 proxies
557
558 // One hop: the right-hand entry is what our proxy saw, and that is the client.
559 const one = { hops: 1 };
560 expect(peer.peerOf(publicProxy, { ...one, forwardedFor: `${forged}, ${client}` })).toEqual({ address: client });
561 // Two hops: one more entry is skipped, and what the client typed is not read.
562 const two = { hops: 2 };
563 expect(peer.peerOf(publicProxy, { ...two, forwardedFor: `${forged}, ${client}, 10.9.9.9` })).toEqual({
564 address: client,
565 });
566 // A chain shorter than the hops we were promised is not the chain we were
567 // promised, so it is not read; nor is an entry that is not an address.
568 expect(peer.peerOf(publicProxy, { ...two, forwardedFor: client })).toEqual({ address: publicProxy });
569 expect(peer.peerOf(publicProxy, { ...one, forwardedFor: "" })).toEqual({ address: publicProxy });
570 expect(peer.peerOf(publicProxy, { ...one, forwardedFor: "not an address" })).toEqual({ address: publicProxy });
571 // IPv6 arrives the same way.
572 expect(peer.peerOf("::1", { ...one, forwardedFor: "fd00::1, 2001:db8::1" })).toEqual({ address: "2001:db8::1" });
573 });
574
575 test("a card's holder is told what happened, not what the shop wrote to itself", () => {
576 const { card } = giftcards.issue({ cents: 4000, note: "a present" });
577 const orderId = bareOrder("BZ-CARD-WORDS");
578 expect(giftcards.spend(card.id, 1000, "paid on BZ-CARD-WORDS", orderId)).toBe(true);
579 giftcards.setActive(card.id, false, "chargeback from the buyer, do not honour");
580
581 const shown = giftcards.publicEntriesOf(card.id);
582 expect(shown.map((entry) => entry.what)).toEqual(["Taken off the card", "Spent on an order", "Issued"]);
583 expect(shown.map((entry) => entry.delta)).toEqual([-3000, -1000, 4000]);
584 for (const entry of shown) expect(entry.what).not.toContain("chargeback");
585
586 // The shop's own view keeps every word of it.
587 expect(giftcards.entriesOf(card.id)[0]!.reason).toBe("chargeback from the buyer, do not honour");
588 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs