Public: anyone can read this; the crew can change it.
Discount codes, gift cards and tax #6
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/3-discount-codes-gift-cards-and-tax → main 12 commits fork 4 threads · 4 unresolved
20 files
changed +1323
−94
README.md +85 −25
| @@ -2,11 +2,12 @@ | ||
| 2 | 2 | |
| 3 | 3 | A small shop: a catalog with categories and search, product pages with |
| 4 | 4 | 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 | |
| 10 | 11 | paged, so a listing is never a page without an end and never hides what is |
| 11 | 12 | past the end of it. |
| 12 | 13 | |
| @@ -52,10 +53,16 @@ takes off. | ||
| 52 | 53 | - **Discount codes** are a percent, a fixed number of cents, or free |
| 53 | 54 | shipping, with an optional minimum, a start and expiry, and a cap on how |
| 54 | 55 | 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. | |
| 59 | 66 | - **Gift cards** are sold as an ordinary product (`/p/gift-card`) and issued |
| 60 | 67 | when the order is paid; the code is printed once, on the receipt. Only the |
| 61 | 68 | code's HMAC and its last four characters are stored, so the table cannot be |
| @@ -63,7 +70,18 @@ takes off. | ||
| 63 | 70 | `gift_card_entry`, and spending is conditional on the balance being there, |
| 64 | 71 | so two checkouts racing for the last $5 cannot both win and a balance never |
| 65 | 72 | 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. | |
| 67 | 85 | - **Tax** is a table of rates in basis points by country and region |
| 68 | 86 | (`/admin/tax`), matched on the address the shopper typed — the exact region |
| 69 | 87 | 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: | ||
| 104 | 122 | A limit only limits if the caller cannot choose which bucket it spends, so |
| 105 | 123 | `src/peer.ts` decides where a request came from and a bucket is keyed on that |
| 106 | 124 | 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 | |
| 108 | 126 | only if a proxy we trust appends to it, and nothing this app can see from the |
| 109 | 127 | inside proves that one does. A client can send that header itself, and a hint |
| 110 | 128 | 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. | ||
| 113 | 131 | The consequence is deliberate and worth knowing: behind the forge's proxy every |
| 114 | 132 | shopper arrives on the same address, so these are the **shop's** limits rather |
| 115 | 133 | 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. | |
| 121 | 148 | |
| 122 | 149 | The address is checked for the shape of one before it is used, which is also |
| 123 | 150 | 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. | ||
| 165 | 192 | | Variable | Meaning | |
| 166 | 193 | | --- | --- | |
| 167 | 194 | | `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. | | |
| 168 | 196 | | `APP_SECRET` | Signs cart cookies, sessions, CSRF tokens and order links. Generated into `FORGE_APP_DATA/secret` when unset. | |
| 169 | 197 | | `STRIPE_SECRET_KEY` | A Stripe **test** key turns on real Stripe Checkout. | |
| 170 | 198 | | `STRIPE_WEBHOOK_SECRET` | Verifies `POST /webhooks/stripe`. | |
| @@ -198,9 +226,24 @@ from `/search?q=beans` comes back to the results — with one exception: an | ||
| 198 | 226 | order's `token` is stripped, because it opens that order to whoever holds it |
| 199 | 227 | and the sign-in path is handed to the forge. Those links also carry |
| 200 | 228 | `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) | |
| 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. | |
| 204 | 247 | |
| 205 | 248 | ## Shape |
| 206 | 249 | |
| @@ -213,18 +256,35 @@ for an admin. | ||
| 213 | 256 | | `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 | |
| 214 | 257 | | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is | |
| 215 | 258 | | `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 | | |
| 216 | 260 | | `src/peer.ts` `src/ratelimit.ts` | Where a request came from, as far as it can be proved; the buckets keyed on it | |
| 217 | 261 | | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox | |
| 218 | 262 | | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` | |
| 219 | 263 | | `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 | | |
| 221 | 265 | |
| 222 | 266 | ## The terminal |
| 223 | 267 | |
| 224 | 268 | The same shop over the same data. The exact command is in `FORGE_APP_SSH` |
| 225 | 269 | 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 { | ||
| 316 | 316 | return render(ctx, { title: "Discounts", admin: true }, pages.discounts(ctx.session, discounts.withUse(), messageFrom(ctx))); |
| 317 | 317 | } |
| 318 | 318 | |
| 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. */ | |
| 320 | 320 | 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); | |
| 322 | 322 | |
| 323 | 323 | export function discountCreate(ctx: Ctx): Response { |
| 324 | 324 | 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 { | |
| 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 => | ||
| 49 | 49 | ? `${formatCents(discount.value)} off` |
| 50 | 50 | : "free shipping"; |
| 51 | 51 | |
| 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 | ||
| 52 | 70 | /** |
| 53 | 71 | * Is this code usable right now, against this much in goods? The answer is |
| 54 | 72 | * advice: `redeem` decides, inside the order's transaction. |
| @@ -57,7 +75,7 @@ export function evaluate(code: string, base_cents: number, now = new Date()): Ch | ||
| 57 | 75 | const discount = byCode(code); |
| 58 | 76 | if (!discount) return { ok: false, error: "That code is not one this shop knows." }; |
| 59 | 77 | 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); | |
| 61 | 79 | if (discount.starts_at && stamp < discount.starts_at) |
| 62 | 80 | return { ok: false, error: `${discount.code} cannot be used yet.` }; |
| 63 | 81 | if (discount.expires_at && stamp > discount.expires_at) |
| @@ -82,14 +100,20 @@ export function evaluate(code: string, base_cents: number, now = new Date()): Ch | ||
| 82 | 100 | /** |
| 83 | 101 | * Take one use of the code for an order. False when there was none left to |
| 84 | 102 | * 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. | |
| 85 | 108 | */ |
| 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 { | |
| 87 | 110 | const changes = db |
| 88 | 111 | .query( |
| 89 | 112 | `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)`, | |
| 91 | 115 | ) |
| 92 | .run(discountId).changes; | |
| 116 | .run(discountId, stampOf(now)).changes; | |
| 93 | 117 | if (changes !== 1) return false; |
| 94 | 118 | db.query( |
| 95 | 119 | "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[] => | ||
| 67 | 67 | ) |
| 68 | 68 | .all(giftCardId, limit); |
| 69 | 69 | |
| 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 | ||
| 70 | 101 | export const entriesForOrder = (orderId: number): (Entry & { last4: string })[] => |
| 71 | 102 | db |
| 72 | 103 | .query<Entry & { last4: string }, [number]>( |
src/html.ts +2 −2
| @@ -90,8 +90,8 @@ export function layout(chrome: Chrome): string { | ||
| 90 | 90 | <footer class="bottom"><div class="wrap"> |
| 91 | 91 | <p><span class="env" style="background: ${env === "production" ? "#16653d" : "#1d4ed8"}">${escape(where)}</span> |
| 92 | 92 | ${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> | |
| 95 | 95 | <p>${ |
| 96 | 96 | ssh |
| 97 | 97 | ? `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 | ||
| 49 | 49 | "", |
| 50 | 50 | "Your gift card" + (giftCardCodes.length === 1 ? "" : "s") + " — this is the only copy of the code:", |
| 51 | 51 | ...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.", | |
| 53 | 54 | ] |
| 54 | 55 | : []), |
| 55 | 56 | "", |
src/orders.ts +1 −1
| @@ -198,7 +198,7 @@ export function place(input: { | ||
| 198 | 198 | if (priced.discount_code !== "") { |
| 199 | 199 | const discount = discounts.byCode(priced.discount_code)!; |
| 200 | 200 | 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.`); | |
| 202 | 202 | note(created.id, "discount", `${discount.code} — ${discounts.describe(discount)}`); |
| 203 | 203 | } |
| 204 | 204 | 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 | ||
| 59 | 59 | ${quote.discount_code !== "" ? remove("discount", quote.discount_code) : ""} |
| 60 | 60 | ${quote.gift_card_last4 !== "" ? remove("gift", `····${quote.gift_card_last4}`) : ""} |
| 61 | 61 | </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> | |
| 62 | 65 | </div>`; |
| 63 | 66 | } |
| 64 | 67 |
src/pages/checkout.ts +12
| @@ -203,6 +203,9 @@ export function confirmation( | ||
| 203 | 203 | who: Who, |
| 204 | 204 | ): string { |
| 205 | 205 | 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); | |
| 206 | 209 | return ` |
| 207 | 210 | <h1>${paid ? "Thank you" : "Order " + escape(order.number)}</h1> |
| 208 | 211 | <p class="lede">Order ${escape(order.number)} · ${statusPill(order.status)} · placed ${escape(order.created_at)} UTC</p> |
| @@ -219,6 +222,15 @@ ${ | ||
| 219 | 222 | — this store has no mail server, so receipts are kept here instead of sent.</p>` |
| 220 | 223 | : "" |
| 221 | 224 | } |
| 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 | } | |
| 222 | 234 | ${itemsTable(items)} |
| 223 | 235 | ${orderTotals(order, refunded)} |
| 224 | 236 | <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 @@ | ||
| 6 | 6 | * socket actually arrived on, which is the one thing here that is not the |
| 7 | 7 | * caller's to write. |
| 8 | 8 | * |
| 9 | * `X-Forwarded-For` is not read. It would be the useful thing — it names the | |
| 10 | * shopper behind a proxy — but only if a proxy we trust appends to it, and | |
| 11 | * nothing this app can see from the inside proves that one does. Anything a | |
| 12 | * client sends arrives in that header too, and a hint nobody vouches for is | |
| 13 | * the caller's own word: a limit keyed on the caller's own word is not a | |
| 14 | * limit, which is what the leftmost entry made of every bucket here before. | |
| 15 | * So it is dropped, and a bucket behind the forge's proxy is the whole shop's | |
| 16 | * — see the README. If the forge ever says it appends a hop of its own, the | |
| 17 | * rightmost entry becomes worth reading and this is the one file that changes. | |
| 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. | |
| 18 | 25 | * |
| 19 | 26 | * Nothing leaves here unchecked. The address keys a bucket and it is also |
| 20 | 27 | * written into `order_event` detail, where an auditor reads it, so it has to |
| @@ -22,13 +29,20 @@ | ||
| 22 | 29 | */ |
| 23 | 30 | |
| 24 | 31 | 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. */ | |
| 26 | 33 | address: string; |
| 27 | 34 | }; |
| 28 | 35 | |
| 29 | 36 | /** The longest an address can be: an IPv6 one with an IPv4 tail inside it. */ |
| 30 | 37 | export const MAX = 45; |
| 31 | 38 | |
| 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 | ||
| 32 | 46 | /** |
| 33 | 47 | * The value as an address, or null. Four octets of at most 255, or hex digits |
| 34 | 48 | * 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 { | ||
| 43 | 57 | return null; |
| 44 | 58 | } |
| 45 | 59 | |
| 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Rate limits. A token bucket per key, in memory: this is one process, and a |
| 3 | 3 | * limit that resets when the app restarts is still a limit. Checkout, payment, |
| 4 | * typing a code and finding an order each get their own bucket so a busy shop | |
| 5 | * cannot lock anyone out of the others. | |
| 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. | |
| 6 | 6 | * |
| 7 | 7 | * A bucket is keyed on the peer's address and nothing else (see src/peer.ts): |
| 8 | 8 | * on a header the caller writes it would be no limit at all. Behind the |
| 9 | * forge's proxy that address is the proxy's, so these are the shop's limits | |
| 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 | |
| 10 | 11 | * rather than each shopper's, and the capacities are set for a shop: enough |
| 11 | 12 | * that ordinary trade never meets them, low enough that guessing at a code or |
| 12 | 13 | * an order number runs out of tries long before it runs out of guesses. |
| @@ -19,11 +20,19 @@ const buckets = new Map<string, Bucket>(); | ||
| 19 | 20 | |
| 20 | 21 | export type Limit = { name: string; capacity: number; perSeconds: number }; |
| 21 | 22 | |
| 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 | */ | |
| 22 | 28 | export const CHECKOUT: Limit = { name: "checkout", capacity: 30, perSeconds: 60 }; |
| 23 | 29 | export const PAYMENT: Limit = { name: "payment", capacity: 60, perSeconds: 60 }; |
| 24 | 30 | // Codes are guessable in principle, so trying them is the slowest thing here. |
| 25 | 31 | 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. | |
| 27 | 36 | export const LOOKUP: Limit = { name: "lookup", capacity: 30, perSeconds: 300 }; |
| 28 | 37 | |
| 29 | 38 | /** True when the caller may proceed; false when the bucket is empty. */ |
src/routes.ts +3
| @@ -29,6 +29,9 @@ export const routes: Route[] = [ | ||
| 29 | 29 | { method: "POST", pattern: "/cart/add", handler: shop.addToCart }, |
| 30 | 30 | { method: "POST", pattern: "/cart/update", handler: shop.updateCart }, |
| 31 | 31 | |
| 32 | { method: "GET", pattern: "/gift-cards", handler: (ctx) => shop.giftCardPage(ctx) }, | |
| 33 | { method: "POST", pattern: "/gift-cards", handler: shop.giftCardCheck }, | |
| 34 | ||
| 32 | 35 | { method: "GET", pattern: "/checkout", handler: (ctx) => shop.checkoutForm(ctx) }, |
| 33 | 36 | { method: "POST", pattern: "/checkout", handler: shop.placeOrder }, |
| 34 | 37 |
src/server.test.ts +177
| @@ -13,6 +13,7 @@ const { server } = await import("./server.ts"); | ||
| 13 | 13 | const { db } = await import("./db.ts"); |
| 14 | 14 | const limit = await import("./ratelimit.ts"); |
| 15 | 15 | const giftcards = await import("./giftcards.ts"); |
| 16 | const discounts = await import("./discounts.ts"); | |
| 16 | 17 | const base = `http://${server.hostname}:${server.port}`; |
| 17 | 18 | |
| 18 | 19 | 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 | ||
| 389 | 390 | .get(order.id)!.amount_cents, |
| 390 | 391 | ).toBe(570); |
| 391 | 392 | |
| 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 | ||
| 392 | 399 | const receipt = await shopper.page(`/orders/${number}/receipt?token=${encodeURIComponent(token)}`); |
| 393 | 400 | expect(receipt).toContain("Discount (WELCOME10)"); |
| 394 | 401 | expect(receipt).toContain("California sales tax (8.75%)"); |
| @@ -569,6 +576,60 @@ test("checkout is rate limited", async () => { | ||
| 569 | 576 | limit.forget(); |
| 570 | 577 | }); |
| 571 | 578 | |
| 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 | ||
| 572 | 633 | test("an unsigned webhook is refused", async () => { |
| 573 | 634 | const response = await fetch(base + "/webhooks/stripe", { |
| 574 | 635 | method: "POST", |
| @@ -755,6 +816,122 @@ test("an admin adds a product and a variant, and the shop sells it", async () => | ||
| 755 | 816 | expect(stockOf("KIT-SPOON-1")).toBe(5); |
| 756 | 817 | }); |
| 757 | 818 | |
| 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 | }); | |
| 758 | 935 | test("an order is found again with its number and the email it was placed with", async () => { |
| 759 | 936 | limit.forget(); |
| 760 | 937 | const shopper = new Shopper(); |
src/server.ts +13 −3
| @@ -25,7 +25,7 @@ import { | ||
| 25 | 25 | import { loadSecret } from "./sign.ts"; |
| 26 | 26 | import { match } from "./routes.ts"; |
| 27 | 27 | import { notFound } from "./shop.ts"; |
| 28 | import { peerOf } from "./peer.ts"; | |
| 28 | import { peerOf, trustedHops } from "./peer.ts"; | |
| 29 | 29 | import { seed } from "./catalog.ts"; |
| 30 | 30 | import { seed as seedDiscounts } from "./discounts.ts"; |
| 31 | 31 | import { seed as seedTax } from "./tax.ts"; |
| @@ -65,6 +65,12 @@ const SECURITY: Record<string, string> = { | ||
| 65 | 65 | // (Sec-Fetch-Site, or Origin); a request that names another site is refused. |
| 66 | 66 | // The app's public address is what the forge told us, never the socket's. |
| 67 | 67 | 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); | |
| 68 | 74 | const sameOrigin = (request: Request): boolean => { |
| 69 | 75 | const site = request.headers.get("sec-fetch-site"); |
| 70 | 76 | if (site !== null) return site === "same-origin" || site === "none"; |
| @@ -176,8 +182,12 @@ function context( | ||
| 176 | 182 | cookies, |
| 177 | 183 | setCookie: (value: string) => outgoing.push(value), |
| 178 | 184 | 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 | }), | |
| 181 | 191 | }; |
| 182 | 192 | } |
| 183 | 193 |
src/shop.ts +31
| @@ -15,6 +15,7 @@ import * as giftcards from "./giftcards.ts"; | ||
| 15 | 15 | import * as limit from "./ratelimit.ts"; |
| 16 | 16 | import * as mine from "./mine.ts"; |
| 17 | 17 | import * as orders from "./orders.ts"; |
| 18 | import * as giftCardPages from "./pages/giftcard.ts"; | |
| 18 | 19 | import * as pages from "./pages/checkout.ts"; |
| 19 | 20 | import * as storefront from "./pages/storefront.ts"; |
| 20 | 21 | import { |
| @@ -190,6 +191,36 @@ export function applyCode(ctx: Ctx): Response { | ||
| 190 | 191 | const codeRefused = (ctx: Ctx, back: string, error: string): Response => |
| 191 | 192 | back === "/checkout" ? checkoutForm(ctx, error) : cartView(ctx, error); |
| 192 | 193 | |
| 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 | ||
| 193 | 224 | export function removeCode(ctx: Ctx): Response { |
| 194 | 225 | const which = field(ctx.form, "which", 10); |
| 195 | 226 | const back = field(ctx.form, "back", 20) === "checkout" ? "/checkout" : "/cart"; |
src/tty.ts +149 −40
| @@ -4,15 +4,26 @@ | ||
| 4 | 4 | * use — the same catalog, the same orders, the same ledger. |
| 5 | 5 | * |
| 6 | 6 | * 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. | |
| 9 | 14 | * |
| 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. | |
| 14 | 21 | */ |
| 15 | 22 | |
| 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"; | |
| 16 | 27 | import { db } from "./db.ts"; |
| 17 | 28 | import { chosen } from "./gateway.ts"; |
| 18 | 29 | import { formatCents } from "./money.ts"; |
| @@ -20,16 +31,16 @@ import { history, addStock, setStock } from "./inventory.ts"; | ||
| 20 | 31 | import { itemsOf, paymentsOf, recent, refund, refundableCents, refundedCents } from "./orders.ts"; |
| 21 | 32 | import { outbox } from "./mail.ts"; |
| 22 | 33 | import { atKeyboard } from "./who.ts"; |
| 23 | import type { Order } from "./db.ts"; | |
| 34 | import type { GiftCard, Order, TaxRate } from "./db.ts"; | |
| 24 | 35 | |
| 25 | 36 | const person = atKeyboard(); |
| 26 | 37 | const isAdmin = person.role === "admin"; |
| 38 | const readable = desk.visible(person).join(", "); | |
| 39 | const onlyReads = ` — ${readable} read, nothing here changes anything`; | |
| 27 | 40 | const whoLine = |
| 28 | 41 | 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}`; | |
| 33 | 44 | |
| 34 | 45 | const where = |
| 35 | 46 | process.env.FORGE_APP_ENV === "preview" |
| @@ -53,7 +64,10 @@ const stockRows = (): Row[] => | ||
| 53 | 64 | ) |
| 54 | 65 | .all(); |
| 55 | 66 | |
| 56 | let tab: "stock" | "orders" | "outbox" = "stock"; | |
| 67 | const TABS = desk.TABS; | |
| 68 | type Tab = desk.Tab; | |
| 69 | ||
| 70 | let tab: Tab = "stock"; | |
| 57 | 71 | let cursor = 0; |
| 58 | 72 | let prompt: { label: string; text: string; run: (value: string) => string } | undefined; |
| 59 | 73 | let message = ""; |
| @@ -61,9 +75,51 @@ let message = ""; | ||
| 61 | 75 | // the same needle /admin/orders takes. |
| 62 | 76 | let filter = ""; |
| 63 | 77 | |
| 78 | // What the orders tab is showing: every order, or the ones `/` narrowed to. | |
| 64 | 79 | const orderRows = (): Order[] => recent(100, filter); |
| 65 | 80 | |
| 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)}` : ""; | |
| 67 | 123 | |
| 68 | 124 | function detail(order: Order): string[] { |
| 69 | 125 | const lines = [""]; |
| @@ -85,11 +141,7 @@ function draw(): void { | ||
| 85 | 141 | const lines: string[] = []; |
| 86 | 142 | lines.push(`${bold("bazaar")} ${dim(where)} ${dim(chosen().label)}`); |
| 87 | 143 | 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()); | |
| 93 | 145 | lines.push(""); |
| 94 | 146 | if (rows.length === 0) lines.push(dim("Nothing here yet.")); |
| 95 | 147 | if (tab === "stock") |
| @@ -116,19 +168,42 @@ function draw(): void { | ||
| 116 | 168 | lines.push(`${mark} ${i === cursor ? bold(email.subject) : email.subject} ${dim(email.to_address)}`); |
| 117 | 169 | if (i === cursor) lines.push("", ...email.body.split("\n").map((line) => ` ${dim(line)}`)); |
| 118 | 170 | }); |
| 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 | } | |
| 119 | 204 | lines.push(""); |
| 120 | 205 | 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`); | |
| 132 | 207 | out(`${esc}2J${esc}H` + lines.join("\r\n")); |
| 133 | 208 | } |
| 134 | 209 | |
| @@ -196,18 +271,52 @@ process.stdin.on("data", (key: string) => { | ||
| 196 | 271 | const count = rowsFor().length; |
| 197 | 272 | if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1)); |
| 198 | 273 | 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) | |
| 206 | 287 | 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) | |
| 211 | 320 | message = history(8) |
| 212 | 321 | .map((entry) => dim(`${entry.at} ${entry.sku} ${entry.delta > 0 ? "+" : ""}${entry.delta} ${entry.reason}`)) |
| 213 | 322 | .join("\r\n"); |
src/units.test.ts +282
| @@ -15,6 +15,7 @@ const mine = await import("./mine.ts"); | ||
| 15 | 15 | const peer = await import("./peer.ts"); |
| 16 | 16 | const { escape } = await import("./html.ts"); |
| 17 | 17 | const catalog = await import("./catalog.ts"); |
| 18 | const desk = await import("./desk.ts"); | |
| 18 | 19 | const discounts = await import("./discounts.ts"); |
| 19 | 20 | const giftcards = await import("./giftcards.ts"); |
| 20 | 21 | const tax = await import("./tax.ts"); |
| @@ -304,3 +305,284 @@ test("the settings the admin password left behind are deleted, and nothing else | ||
| 304 | 305 | expect(getSetting("admin_salt")).toBeUndefined(); |
| 305 | 306 | expect(getSetting("tax_default_bp")).toBe("800"); |
| 306 | 307 | }); |
| 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 | }); |
I bought
/p/gift-cardon the preview, paid the mock gateway, tookGC-····M8RGoff 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. Theredeemfix is the best part of this pull — putting the window into the same conditionalUPDATEthat 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;mainis now1d0fbed, and the pull does not merge (conflicts inREADME.md,src/tty.ts,src/units.test.ts).src/tty.tshere importsensureDemoPasswordfromsrc/session.tsand prints/admin password (generated, …)on the first screen — that export is gone frommain, so the terminal will not even start after the merge. More important:main's tty takes who is at the keyboard fromatKeyboard()and gatessandronperson.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 throughsshis as privileged as/admin". That is the assumptionmaindeliberately dropped. Fix first: rebase ontomain, delete the generated-password lines fromsrc/tty.tsand the README, and putisAdminin front of every desk action exactly asmaindoes forsandr(if (key === "i" && tab === "cards" && isAdmin), and the same for the rest —c/lookprints 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-cardsdoes not hold in front of a real client. Fourteen POSTs from here with a rotatingx-forwarded-forall answered 200; the same fourteen without the header hit 429 withretry-after: 10after ten.context()insrc/server.tsbuildsctx.ipfrom 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: preferself.requestIP()and only consultx-forwarded-forwhen the forge's proxy is known to be in front, taking the last hop rather than the first.src/server.tsis 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.
entriesOfreasons are printed verbatim, so the reason an admin types when voiding a card (vprompts "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/:numbershows 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.