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

An order belongs to the account that bought it: sign in to buy, and /orders lists mine #32

Merged opened this

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

Merged

ledger/bazaar:ledger/10-an-order-belongs-to-the-account-that-bou → main 11 commits fork 2 threads · 2 unresolved

  • Merged. ledger/10-an-order-belongs-to-the-account-that-bou was merged into main as 9c72f19.

Review

Sign in to review this change.
23 files changed +1192 −393
README.md +125 −49
@@ -4,10 +4,12 @@ A small shop: a catalog with categories and search, product pages with
44 variants and stock, a cart in a signed cookie, discount codes and gift cards,
55 tax by where the parcel goes, a gift card balance window, checkout with an
66 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
7 codes, cards, tax and orders with refunds. Looking around and filling a cart
8 need no account; buying needs one, because an order belongs to the account
9 that placed it and `/orders` is that account's list of them. Every price is an
10 integer number of cents, every form carries a CSRF token, and checkout,
11 payment, typing a code, reading a gift card's balance and finding an order are
12 each rate limited on their own. A category or a search longer than 24 things is
1113 paged, so a listing is never a page without an end and never hides what is
1214 past the end of it.
1315
@@ -106,26 +108,71 @@ the goods, plus gift cards being bought — and only then is a card's balance
106108 taken off to leave what the gateway is asked for. `bun test` reconciles a
107109 cart with all three against the order it becomes.
108110
109 ## Finding an order again
111 ## An account buys; browsing does not need one
110112
111 An order is private, so it opens with its own access token and nothing else —
112 which leaves the shopper who closed the tab with no way back. `/orders` is
113 that way back, and it has two halves:
113 `/`, a category, a product page, the cart, adding to it, changing it and
114 typing a code all work signed out, exactly as they always did. Buying is where
115 an account starts, because an order has to belong to somebody:
114116
115 - **From this browser.** Placing an order appends its number to a signed
116 `mine` cookie, at most ten deep. The cookie holds numbers and nothing else;
117 the link is built from the access token read out of the row, so editing the
118 cookie changes which number is being claimed and never what opens. A shared
119 computer is not a shopper's, so **Forget these orders** drops the cookie.
120 - **The form.** An order number and the email it was placed with. Both have to
121 be right, the email is compared as a keyed digest so no letter of it times
122 differently — and a number that matches nothing is digested against a fixed
123 string, so a miss does the same work as a match — and a pair that does not
124 match is refused in the same words whether or not the number exists. This
125 form is not a way to learn which order numbers are real. It is rate limited
126 like the other guessable things (see below), and a lookup that succeeds
127 leaves a `looked-up` row in the order's events with the address it came
128 from.
117 - `GET /checkout` for a shopper the forge has not signed in shows the same
118 order summary with the address form replaced by **Sign in to buy**, pointing
119 at `/__forge/signin?to=/checkout`. Nothing about the cart is touched, so the
120 same cart is there on the way back. The cart page says it too, with a full
121 cart in front of the shopper rather than after they have typed an address.
122 - `POST /checkout` without an account writes nothing at all — no order row, no
123 stock taken, no code redeemed — and answers with a 303 back to `/checkout`.
124 - An order records `buyer_id` and `buyer_name` from the verified identity. The
125 `email` column is still the address the shopper typed: a forge account name
126 is not an email address and is never written there. The email box is
127 prefilled only when what the forge gave us is itself an email.
128
129 The account is also who an order opens for. `src/shop.ts`'s `openOrder` is the
130 only place that decides, and it knows three ways in: an admin, the account the
131 order belongs to, and whoever holds the order's own access token, compared in
132 constant time. So `/orders/<number>`, its receipt and `/pay/<number>` open for
133 their buyer with no `?token=` at all, and a signed-in stranger gets the same
134 404 a stranger has always got.
135
136 ### What the access token is still for
137
138 It is not dead code. The orders placed before accounts existed — `buyer_id` is
139 `''` on every one of them, added with `addColumn` and a default so they keep
140 rendering — have nothing else, and the token is still what travels: in the
141 receipt, in the mock gateway's forms, and in the `success_url` and
142 `cancel_url` Stripe sends a shopper back to. `to=` and `rel="noreferrer"` keep
143 it out of the sign-in links, as before.
144
145 ## Finding a guest order again
146
147 `/orders` is an account's own orders, newest first — number, date, status,
148 total and a link that needs no token. A shopper who is not signed in is
149 offered the way in and never a list of anything. Under the list are the two
150 halves of the way back to a guest order:
151
152 - **Found in this browser.** A guest order found here appends its number to a
153 signed `mine` cookie, at most ten deep — found through the form below, or by
154 opening the order with its own token, which is the same browser finding the
155 same order and is what makes signing in mid-purchase land somewhere useful. The cookie holds numbers and nothing
156 else; the link is built from the access token read out of the row, so editing
157 the cookie changes which number is being claimed and never what opens. A
158 shared computer is not a shopper's, so **Forget these orders** drops the
159 cookie. An order an account owns is listed from the account, so it is never
160 put in the cookie. This half is shown to whoever is holding the cookie,
161 signed in or not: a guest order belongs to a browser, and a list nobody can
162 see is a list nobody can drop.
163 - **The form, for a guest order and nothing else.** An order number and the
164 email it was placed with. An order that belongs to an account is *not* found
165 this way — it has a door of its own, and a number and an email are not proof
166 of being its buyer — so it is refused in the words a miss gets, after the
167 same work, and leaves nothing in its trail. Both fields have to be right, the
168 email is compared as a keyed digest so no letter of it times differently —
169 and a number that matches nothing is digested against a fixed string, so a
170 miss does the same work as a match — and every refusal is the same words
171 whether the number does not exist, does not match, or is not this form's to
172 hand over. This form is not a way to learn which order numbers are real, nor
173 which of them are owned. It is rate limited like the other guessable things
174 (see below), and a lookup that succeeds leaves a `looked-up` row in the
175 order's events with the address it came from.
129176
130177 ### What a rate limit is keyed on
131178
@@ -236,8 +283,8 @@ session cookie is still there, but it only carries the CSRF token now.
236283
237284 | Who | What |
238285 | --- | --- |
239 | **anon** | Reads the shop: catalog, search, product pages, the cart, and an order with the order's own token. |
240 | **user** | Buys: everything anon can do, and the header names them. |
286 | **anon** | Reads the shop: catalog, search, product pages, the cart with everything it does, and an order with the order's own token. Cannot buy. |
287 | **user** | Buys: everything anon can do, and an order of their own — `/orders` lists it, their order, receipt and payment pages open without a token, and the terminal's orders tab shows the same list. |
241288 | **admin** | Runs the shop: every path under `/admin`, and any order without its token. |
242289
243290 Anyone who is not an admin gets one page at `/admin`, status 403, whose only
@@ -248,23 +295,50 @@ from `/search?q=beans` comes back to the results — with one exception: an
248295 order's `token` is stripped, because it opens that order to whoever holds it
249296 and the sign-in path is handed to the forge. A page the token was what opened
250297 (`/pay/:number`, `/orders/:number`, its receipt) is a 404 without it, so those
251 come back to `/orders` instead — "Find an order" opens for anybody, and lists
252 this browser's orders with their own links, so a shopper who signs in
253 part-way through paying is one click from finishing. Those links also carry
298 come back to `/orders` instead, which opens for anybody: the account's own
299 orders once they are signed in, and under them the guest orders this browser
300 has opened, each with its own token link — so a shopper who signs in part-way
301 through paying is one click from finishing either way. Those links also carry
254302 `rel="noreferrer"`: `/__forge/*` shares this origin, so the `Referer` would
255303 otherwise hand over the very URL `to` was careful to clean.
256304
257 The terminal takes the same three roles from the environment. Stock and the
258 tax table read for anybody the forge names; orders,
259 the outbox, the codes, the cards and the changes do not, because a receipt
260 carries a gift card's code in full, an order carries a name and an address,
261 the card list is every balance in the shop, and the changes name the people
262 who run it. Every key that does anything — moving stock,
263 refunding, writing a code, issuing or voiding a card, setting a rate — is an
264 admin's, and so are the two keys that only read something out: `c`, which
265 prints a balance, and `h`, which prints the stock ledger. `src/desk.ts` holds
266 one table for both, and the tab bar and the foot of the screen are built from
267 it, so nothing can be offered without being gated.
305 A page only a POST reaches is the same dead end: a missed lookup at
306 `POST /orders/find` renders "Your orders", and a declined mock payment at
307 `POST /pay/:number/mock` renders the payment page, but a GET of either is a
308 404. So `to` is worked out once, at the front door, and handed to every page
309 as `ctx.backTo`: this URL when a GET renders a page for the route's own
310 pattern — `getAnswers` in `src/routes.ts`, asked of the pattern, because
311 `/orders/find` is *shaped* like `/orders/:number` — and `/orders` when it does
312 not. No page chooses its own sign-in target, so none of them can choose a 404.
313
314 The token goes the other way too: nobody is handed one they did not need.
315 `src/orderlink.ts` says where an order lives for the person being sent there —
316 the bare path for its buyer and for an admin, `?token=…` for whoever it took a
317 token to get here — and `openOrder` reads the same predicate, so a buyer who
318 opened their order by account never finds its secret written into their
319 history by a redirect or a link on the page. Stripe's `success_url` and
320 `cancel_url` still carry it: that browser leaves this origin, and a forge
321 session that lapses while it is away must not make the way back a 404.
322
323 The terminal takes the same three roles from the environment, and gives each
324 of them what the pages give it. Stock and the tax table read for anybody the
325 forge names. The outbox, the codes, the cards and the changes read for an
326 admin alone, because a receipt carries a gift card's code in full, the card
327 list is every balance in the shop, and the changes name the people who run it.
328 Orders are neither, because `/orders` is neither: whoever the forge named sees
329 the orders on their own account and nobody else's, an admin sees the shop's,
330 and an anonymous keyboard cannot open the tab at all.
331 `desk.ordersFor(who, filter)` is the one place those rows are chosen — a
332 function from `Who` to rows, so the rule is tested without a pty.
333
334 Every key that does anything — moving stock, refunding, writing a code,
335 issuing or voiding a card, setting a rate — is an admin's, and so are the two
336 keys that only read something out: `c`, which prints a balance, and `h`, which
337 prints the stock ledger. The exception is `/`, which narrows the rows already
338 in front of whoever pressed it: it reaches no further than they can already
339 see, so the buyer at the keyboard may press it on their own orders.
340 `src/desk.ts` holds one table for all of this, and the tab bar and the foot of
341 the screen are built from it, so nothing can be offered without being gated.
268342
269343 Rate limits count the address the socket came from, never the first entry of
270344 `x-forwarded-for` — see [What a rate limit is keyed on](#what-a-rate-limit-is-keyed-on)
@@ -357,11 +431,12 @@ saying so.
357431 | `src/db.ts` | The schema, and `open(dir)`: a directory in, a database out |
358432 | `src/fresh.ts` | A database of a test file's own, opened before the file imports anything that queries |
359433 | `src/catalog.ts` `src/inventory.ts` `src/orders.ts` | Products, the stock ledger, orders and refunds |
360 | `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 |
434 | `src/cart.ts` `src/codes.ts` `src/mine.ts` `src/money.ts` `src/sign.ts` | The signed cart, the codes cookie, the guest orders this browser found, integer cents, HMACs |
361435 | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is |
362436 | `src/who.ts` `src/session.ts` | Who the forge says is asking; the session that carries the CSRF token |
437 | `src/orderlink.ts` | Where an order lives for the person being handed the link: with its token, or without it |
363438 | `src/changes.ts` | What an admin changed to the catalog, a code or a rate, and who they were |
364 | `src/desk.ts` | The terminal's typed commands: a line of text into a code, a card or a rate |
439 | `src/desk.ts` | The terminal's gate and its typed commands: who may read which tab and press which key, whose orders they see, and a line of text into a code, a card or a rate |
365440 | `src/peer.ts` `src/ratelimit.ts` | Where a request came from, as far as it can be proved; the buckets keyed on it |
366441 | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox |
367442 | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` |
@@ -414,22 +489,23 @@ live stream within about a second, not on that page's next load. `1`–`7`
414489 switch between stock, orders, the outbox, discount codes, gift cards, the tax
415490 table and the changes an admin made to any of it — everything `/admin`
416491 can see — and `j`/`k` move. A tab whose rows are the shop's own business is an
417 admin's to open: a non-admin gets stock and tax, the other five are marked
418 with a `·` in the tab bar and answer "That is an admin's to do." without
419 leaving the tab they are on:
492 admin's to open: a non-admin gets stock, tax and their own orders, the other
493 four are marked with a `·` in the tab bar and answer without leaving the tab
494 they are on:
420495
421496 | Tab | Keys |
422497 | --- | --- |
423498 | stock | `s` moves stock with a reason, `h` shows the last of the ledger |
424 | 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 |
499 | orders | the orders you may see — your account's, or the shop's for an admin. `/` narrows them by number, name or email, and `r` (an admin's) refunds what is left of one and puts it back on the shelf |
425500 | 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 |
426501 | 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 |
427502 | 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 |
428503 | changes | reads only: the last hundred changes an admin made to the catalog, the codes or the tax table, newest first — the terminal's half of `/admin/changes`. A change made here writes one of these rows too, naming the account the forge put at the keyboard |
429504
430505 `q` leaves. The first line says who the forge named and what they may read;
431 `FORGE_APP_USER_ROLE` has to be `admin` for any of the keys above, `c` and `h`
432 included — one prints a card's balance and a code's full terms, the other the
433 stock ledger. `ssh` gets you to the screen, not past the door. The parsing,
434 the gates and the refusals are `src/desk.ts`, so they are tested without a
435 pty.
506 `FORGE_APP_USER_ROLE` has to be `admin` for every key above except `/` — `c`
507 and `h` included, since one prints a card's balance and a code's full terms
508 and the other the stock ledger. `ssh` gets you to the screen, not past the
509 door, and what is on the screen is only ever what the same person could open
510 on the web. The parsing, the gates, the choice of rows and the refusals are
511 all `src/desk.ts`, so they are tested without a pty.
src/context.ts +8 −2
@@ -11,7 +11,6 @@ import type { Applied } from "./codes.ts";
1111 import type { CartLine } from "./cart.ts";
1212 import type { Peer } from "./peer.ts";
1313 import type { Session } from "./session.ts";
14 import { returnTo } from "./who.ts";
1514 import type { Who } from "./who.ts";
1615
1716 export type Ctx = {
@@ -22,6 +21,13 @@ export type Ctx = {
2221 session: Session;
2322 /** Who the forge says is asking, verified in src/who.ts. */
2423 who: Who;
24 /**
25 * Where signing in comes back to: this page when a GET answers it, and a
26 * page that does when it does not — see `returnTo` in src/who.ts. Worked
27 * out once, at the front door, so no page can offer a way in that lands on
28 * a 404.
29 */
30 backTo: string;
2531 /** Parsed for POST routes; null for GET and for routes that read the body themselves. */
2632 form: FormData | null;
2733 /**
@@ -54,7 +60,7 @@ export function render(
5460 layout({
5561 title: options.title,
5662 who: ctx.who,
57 path: returnTo(ctx.url),
63 path: ctx.backTo,
5864 cartCount: cartCount(ctx.cart),
5965 categories: categories(),
6066 current: options.current,
src/db.ts +9
@@ -271,6 +271,11 @@ function migrate(handle: Database): void {
271271 addColumn(handle, "order", "tax_label", "TEXT NOT NULL DEFAULT ''");
272272 addColumn(handle, "variant", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
273273 addColumn(handle, "order_item", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0");
274 // The account an order belongs to. Empty on the orders placed before there
275 // were accounts: those stay guest orders, reached with their access token.
276 addColumn(handle, "order", "buyer_id", "TEXT NOT NULL DEFAULT ''");
277 addColumn(handle, "order", "buyer_name", "TEXT NOT NULL DEFAULT ''");
278 handle.exec('CREATE INDEX IF NOT EXISTS order_buyer ON "order"(buyer_id, id DESC)');
274279 }
275280
276281 /**
@@ -341,6 +346,10 @@ export type Order = {
341346 number: string;
342347 access_token: string;
343348 status: string;
349 /** The forge account that placed it; "" for a guest order from before accounts. */
350 buyer_id: string;
351 /** That account's name as it was at the sale. Never an email address. */
352 buyer_name: string;
344353 email: string;
345354 name: string;
346355 address1: string;
src/desk.ts +84 −31
@@ -7,21 +7,28 @@
77 * be tested without a pty — a command is text in and a message out, and the
88 * work itself goes through the same modules the admin pages use.
99 *
10 * Reaching the terminal is not permission to use it. Everything below is what
10 * Reaching the terminal is not permission to use it. The work below is what
1111 * /admin does — issuing a card prints its code once, and looking a code up
1212 * reads out a balance — so the forge has to have named an admin at the
1313 * keyboard. `DESK` is the one place that says so, for reading as much as for
1414 * doing: the gate in `src/tty.ts` and the foot of its screen both read it, so
1515 * a key cannot be offered without being gated or gated without being offered,
1616 * and a tab cannot be opened by someone who may not read what is on it.
17 *
18 * Reading is not all or nothing, though, because the web pages are not: what a
19 * person can do on the page they should be able to do here. `ordersFor` is
20 * `/orders` in the terminal — an account's own orders and nobody else's — and
21 * it is a function from `Who` to rows, so the rule is tested without a pty.
1722 */
1823
1924 import * as changes from "./changes.ts";
2025 import * as discounts from "./discounts.ts";
2126 import * as giftcards from "./giftcards.ts";
27 import * as orders from "./orders.ts";
2228 import * as tax from "./tax.ts";
2329 import { db } from "./db.ts";
2430 import { formatCents, parseCents } from "./money.ts";
31 import type { Order } from "./db.ts";
2532 import type { Who } from "./who.ts";
2633
2734 export type Done = { ok: boolean; message: string };
@@ -39,50 +46,67 @@ export type Tab = (typeof TABS)[number];
3946
4047 /**
4148 * Every tab, who may look at it, and the keys it offers — each key with the
42 * words the screen's foot uses.
49 * words the screen's foot uses and who may press it.
4350 *
44 * `read: "admin"` is not squeamishness about internal pages. An order names a
45 * person and where they live; a receipt in the outbox carries a gift card's
46 * code in full, which is bearer money; the card list is every balance in the
47 * shop and the notes the shop wrote to itself about them; the code list is
48 * every discount that exists, including the ones nobody was told about. That
49 * is the same reason `c` is an admin's key, so it is the same gate.
51 * `read: "admin"` is not squeamishness about internal pages. A receipt in the
52 * outbox carries a gift card's code in full, which is bearer money; the card
53 * list is every balance in the shop and the notes the shop wrote to itself
54 * about them; the code list is every discount that exists, including the ones
55 * nobody was told about. That is the same reason `c` is an admin's key, so it
56 * is the same gate.
57 *
58 * `read: "own"` is the rule the orders page has: an order belongs to the
59 * account that bought it, so the rows a person sees here are the rows `/orders`
60 * would list for them — their own and nobody else's. An admin sees the shop's.
61 * Whoever the forge did not name sees nothing, because nothing is theirs.
5062 *
5163 * Stock and the tax table are the shop's public face — what is on the shelf
5264 * and what it is taxed at — so they stay open to whoever the forge named.
65 *
66 * A key marked `"reader"` changes nothing and reads nothing that is not
67 * already on the tab, so whoever may read the tab may press it; every key that
68 * does the desk's work, or reads a code out, is an `"admin"`'s.
5369 */
54 const DESK: Record<Tab, { read: "anyone" | "admin"; keys: { key: string; label: string }[] }> = {
55 stock: { read: "anyone", keys: [{ key: "s", label: "s move stock" }, { key: "h", label: "h ledger" }] },
56 orders: {
57 read: "admin",
70 type Offer = { key: string; label: string; press: "admin" | "reader" };
71
72 const DESK: Record<Tab, { read: "anyone" | "own" | "admin"; keys: Offer[] }> = {
73 stock: {
74 read: "anyone",
5875 keys: [
59 { key: "/", label: "/ find" },
60 { key: "r", label: "r refund the rest and restock" },
76 { key: "s", label: "s move stock", press: "admin" },
77 { key: "h", label: "h ledger", press: "admin" },
78 ],
79 },
80 orders: {
81 read: "own",
82 keys: [
83 { key: "/", label: "/ find", press: "reader" },
84 { key: "r", label: "r refund the rest and restock", press: "admin" },
6185 ],
6286 },
6387 outbox: { read: "admin", keys: [] },
6488 codes: {
6589 read: "admin",
6690 keys: [
67 { key: "n", label: "n new code" },
68 { key: "t", label: "t on/off" },
69 { key: "c", label: "c check a code" },
91 { key: "n", label: "n new code", press: "admin" },
92 { key: "t", label: "t on/off", press: "admin" },
93 { key: "c", label: "c check a code", press: "admin" },
7094 ],
7195 },
7296 cards: {
7397 read: "admin",
7498 keys: [
75 { key: "i", label: "i issue a card" },
76 { key: "v", label: "v void/revive" },
77 { key: "c", label: "c check a code" },
99 { key: "i", label: "i issue a card", press: "admin" },
100 { key: "v", label: "v void/revive", press: "admin" },
101 { key: "c", label: "c check a code", press: "admin" },
78102 ],
79103 },
80104 tax: {
81105 read: "anyone",
82106 keys: [
83 { key: "e", label: "e set a rate" },
84 { key: "d", label: "d default rate" },
85 { key: "x", label: "x remove" },
107 { key: "e", label: "e set a rate", press: "admin" },
108 { key: "d", label: "d default rate", press: "admin" },
109 { key: "x", label: "x remove", press: "admin" },
86110 ],
87111 },
88112 // Who changed a price, a code or a rate is the shop's own business, and it
@@ -90,19 +114,48 @@ const DESK: Record<Tab, { read: "anyone" | "admin"; keys: { key: string; label:
90114 changes: { read: "admin", keys: [] },
91115 };
92116
93 /** True when this key on this tab is the desk's work, and so an admin's. */
94 export const needsAdmin = (tab: Tab, key: string): boolean => DESK[tab].keys.some((offer) => offer.key === key);
117 /**
118 * True when this key on this tab is the desk's work, and so an admin's. The
119 * terminal asks `mayPress`, which is this and the tab's reading rule together;
120 * this is here on its own because it is the half the invariant is stated in:
121 * nothing an admin's key does is ever in `offered` for anybody else.
122 */
123 export const needsAdmin = (tab: Tab, key: string): boolean =>
124 DESK[tab].keys.some((offer) => offer.key === key && offer.press === "admin");
95125
96 /** True when what is printed on this tab is an admin's to read. */
97 export const needsAdminToRead = (tab: Tab): boolean => DESK[tab].read === "admin";
126 /** True when this tab has anything at all to say to this person. */
127 export const canRead = (tab: Tab, who: Who): boolean =>
128 who.role === "admin" || DESK[tab].read === "anyone" || (DESK[tab].read === "own" && who.role !== "anon");
98129
99130 /** The tabs this person may open. A non-admin gets the ones that are not private. */
100 export const visible = (who: Who): Tab[] =>
101 TABS.filter((tab) => who.role === "admin" || !needsAdminToRead(tab));
131 export const visible = (who: Who): Tab[] => TABS.filter((tab) => canRead(tab, who));
102132
103 /** What this person may do on this tab. Nothing, unless the forge named an admin. */
133 /** True when this person may press this key on this tab. */
134 export const mayPress = (tab: Tab, key: string, who: Who): boolean =>
135 DESK[tab].keys.some(
136 (offer) => offer.key === key && (offer.press === "admin" ? who.role === "admin" : canRead(tab, who)),
137 );
138
139 /** True when this key means anything on this tab, whoever is pressing it. */
140 export const knows = (tab: Tab, key: string): boolean => DESK[tab].keys.some((offer) => offer.key === key);
141
142 /** What this person may do on this tab, in the words the screen's foot uses. */
104143 export const offered = (tab: Tab, who: Who): string[] =>
105 who.role === "admin" ? DESK[tab].keys.map((offer) => offer.label) : [];
144 DESK[tab].keys.filter((offer) => mayPress(tab, offer.key, who)).map((offer) => offer.label);
145
146 /**
147 * The orders this person may see at the keyboard, narrowed by what `/` is
148 * looking for. It is `/orders`' rule, said once for the terminal: an admin
149 * sees the shop's orders, anybody else the forge named sees the ones on their
150 * own account, and whoever the forge did not name sees none — there is nothing
151 * here that is theirs. A pty is not needed to check it: `Who` in, rows out.
152 */
153 export const ordersFor = (who: Who, filter = "", limit = 100): Order[] =>
154 who.role === "admin"
155 ? orders.recent(limit, filter)
156 : who.role === "user"
157 ? orders.forBuyer(who.id, limit, filter)
158 : [];
106159
107160 const no = (message: string): Done => ({ ok: false, message });
108161 const yes = (message: string): Done => ({ ok: true, message });
src/html.ts +1 −1
@@ -99,7 +99,7 @@ export function layout(chrome: Chrome): string {
9999 <p><span class="env" style="background: ${env === "production" ? "#16653d" : "#1d4ed8"}">${escape(where)}</span>
100100 ${commit ? `<span class="muted"> @ ${escape(commit)}</span>` : ""}
101101 — a demonstration store: no goods ship and no money moves.</p>
102 <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>
102 <p><a href="/orders">Your orders</a> · <a href="/gift-cards">Gift cards</a> — buy one, or see what is left on the one you hold.</p>
103103 <p>${
104104 ssh
105105 ? `Same shop in a terminal: <code>${escape(ssh)}</code>`
src/mail.ts +3 −1
@@ -68,7 +68,9 @@ export function receiptText(order: Order, items: OrderItem[], giftCardCodes: str
6868 ? "a gift card, in full"
6969 : "the mock gateway"
7070 }.`,
71 `Lost the link to this order? The "Find an order" page takes ${order.number} with this email address.`,
71 order.buyer_name === ""
72 ? `Lost the link to this order? The "Your orders" page takes ${order.number} with this email address.`
73 : `This order belongs to ${order.buyer_name}: it is listed on the "Your orders" page, signed in as them.`,
7274 "This is a demonstration store: no goods ship and no money moves.",
7375 ].join("\n");
7476 }
src/mine.ts +6 −2
@@ -1,11 +1,15 @@
11 /**
2 * The orders this browser has placed, in a signed cookie beside the cart.
2 * The guest orders this browser has found, in a signed cookie beside the cart.
3 *
4 * An order placed by an account is listed from the account, so what is left
5 * for a cookie is the guest orders — the rows from before an order had an
6 * account, which have nothing but their access token.
37 *
48 * Numbers and nothing else: the access token that actually opens an order is
59 * read from the database on every render, so editing this cookie can only
610 * change which order number is being claimed — and a number alone opens
711 * nothing, because the link is built from the token in the row. Ten is as far
8 * back as a cookie should remember; older orders are found with the form at
12 * back as a cookie should remember; anything older is found with the form at
913 * `/orders`, which asks for the email as well.
1014 */
1115
src/orders.ts +45 −5
@@ -38,6 +38,9 @@ export type Address = {
3838 country: string;
3939 };
4040
41 /** The account an order belongs to: who the forge signed in, and nothing else. */
42 export type Buyer = { id: string; name: string };
43
4144 export type PlaceResult = { ok: true; order: Order; reused: boolean } | { ok: false; error: string };
4245
4346 const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
@@ -69,16 +72,41 @@ export const note = (orderId: number, kind: string, detail = ""): void => {
6972 db.query("INSERT INTO order_event (order_id, kind, detail) VALUES (?1, ?2, ?3)").run(orderId, kind, detail);
7073 };
7174
75 /** A needle for LIKE: the shopper's text is data, and `%` and `_` are theirs to type. */
76 const like = (needle: string): string => `%${needle.replace(/[\\%_]/g, (c) => "\\" + c)}%`;
77
78 /**
79 * The orders one account placed, newest first, narrowed by a needle the way
80 * the admin's search is. An empty id belongs to nobody, so it matches nothing:
81 * the guest orders are not one account's to list. Whatever is typed, the
82 * `buyer_id` is the caller's, so this can only narrow — never widen.
83 */
84 export const forBuyer = (buyerId: string, limit = 50, query = ""): Order[] => {
85 if (buyerId === "") return [];
86 const needle = query.trim();
87 if (needle === "")
88 return db
89 .query<Order, [string, number]>('SELECT * FROM "order" WHERE buyer_id = ?1 ORDER BY id DESC LIMIT ?2')
90 .all(buyerId, limit);
91 return db
92 .query<Order, [string, string, number]>(
93 `SELECT * FROM "order" WHERE buyer_id = ?1
94 AND (number LIKE ?2 ESCAPE '\\' OR email LIKE ?2 ESCAPE '\\' OR name LIKE ?2 ESCAPE '\\')
95 ORDER BY id DESC LIMIT ?3`,
96 )
97 .all(buyerId, like(needle), limit);
98 };
99
72100 export const recent = (limit = 50, query = ""): Order[] => {
73101 const needle = query.trim();
74102 if (needle === "") return db.query<Order, [number]>('SELECT * FROM "order" ORDER BY id DESC LIMIT ?1').all(limit);
75 const like = `%${needle.replace(/[\\%_]/g, (c) => "\\" + c)}%`;
76103 return db
77104 .query<Order, [string, number]>(
78105 `SELECT * FROM "order" WHERE number LIKE ?1 ESCAPE '\\' OR email LIKE ?1 ESCAPE '\\' OR name LIKE ?1 ESCAPE '\\'
106 OR buyer_name LIKE ?1 ESCAPE '\\'
79107 ORDER BY id DESC LIMIT ?2`,
80108 )
81 .all(like, limit);
109 .all(like(needle), limit);
82110 };
83111
84112 /** Remember the gateway's handle for an unpaid order (a Stripe session id). */
@@ -123,6 +151,8 @@ export function splitRefund(order: Order, amount_cents: number): { gateway_cents
123151 */
124152 export function place(input: {
125153 address: Address;
154 /** The account buying. An order is nobody's only if it was placed before accounts. */
155 buyer: Buyer;
126156 cart: CartLine[];
127157 codes: Applied;
128158 /** What the checkout page showed, to be told when it went stale. */
@@ -163,10 +193,12 @@ export function place(input: {
163193 try {
164194 created = db
165195 .query<Order, any>(
166 `INSERT INTO "order" (number, access_token, email, name, address1, address2, city, region, postal, country,
196 `INSERT INTO "order" (number, access_token, buyer_id, buyer_name, email, name, address1, address2, city,
197 region, postal, country,
167198 subtotal_cents, discount_code, discount_cents, shipping_cents, tax_cents, tax_rate_bp, tax_label,
168199 gift_card_cents, total_cents, gateway, idempotency_key)
169 VALUES ($number, $access_token, $email, $name, $address1, $address2, $city, $region, $postal, $country,
200 VALUES ($number, $access_token, $buyer_id, $buyer_name, $email, $name, $address1, $address2, $city,
201 $region, $postal, $country,
170202 $subtotal_cents, $discount_code, $discount_cents, $shipping_cents, $tax_cents, $tax_rate_bp, $tax_label,
171203 $gift_card_cents, $total_cents, $gateway, $idempotency_key)
172204 RETURNING *`,
@@ -174,6 +206,8 @@ export function place(input: {
174206 .get({
175207 $number: orderNumber(),
176208 $access_token: randomBytes(18).toString("base64url"),
209 $buyer_id: input.buyer.id.slice(0, 64),
210 $buyer_name: input.buyer.name.slice(0, 120),
177211 $email: input.address.email,
178212 $name: input.address.name,
179213 $address1: input.address.address1,
@@ -233,7 +267,13 @@ export function place(input: {
233267 if (!takeStock(item.variant_id, item.quantity, `sold on ${created.number}`, created.id))
234268 throw new Error(`OUT_OF_STOCK:${item.product_title} (${item.variant_name})`);
235269 }
236 note(created.id, "placed", `${itemRows.length} line${itemRows.length === 1 ? "" : "s"}, awaiting payment`);
270 note(
271 created.id,
272 "placed",
273 `${itemRows.length} line${itemRows.length === 1 ? "" : "s"}, awaiting payment${
274 created.buyer_name === "" ? "" : ` — ${created.buyer_name}`
275 }`,
276 );
237277 return created;
238278 })();
239279 } catch (error) {
src/pages/admin.ts +14 −3
@@ -100,6 +100,14 @@ ${live(
100100 ${live("latest-orders", ORDER_EVENTS, orderRows(latest))}
101101 `;
102102
103 /**
104 * The account an order belongs to, beside the email the shopper typed. They
105 * are different things: an account name is not an email address, and an order
106 * from before accounts belongs to nobody but whoever holds its token.
107 */
108 const account = (order: Order): string =>
109 order.buyer_name === "" ? "guest" : `account ${escape(order.buyer_name)}`;
110
103111 export function orderRows(orders: Order[]): string {
104112 if (orders.length === 0) return '<p class="muted">No orders yet.</p>';
105113 // Fixed columns: a row arriving at the top cannot move the ones below it
@@ -112,7 +120,9 @@ export function orderRows(orders: Order[]): string {
112120 (order) => `<tr>
113121 <td><a href="/admin/orders/${escape(order.number)}">${escape(order.number)}</a></td>
114122 <td class="muted">${escape(order.created_at)}</td>
115 <td>${escape(order.name)}<div class="muted" style="font-size:.85rem">${escape(order.email)}</div></td>
123 <td>${escape(order.name)}<div class="muted" style="font-size:.85rem">${escape(order.email)} · ${account(
124 order,
125 )}</div></td>
116126 <td>${statusPill(order.status)}</td>
117127 <td class="num price">${escape(formatCents(order.total_cents))}</td>
118128 </tr>`,
@@ -124,7 +134,8 @@ export const orders = (list: Order[], query: string): string => `
124134 ${nav("orders")}
125135 <h1>Orders</h1>
126136 <form method="get" action="/admin/orders" class="row-actions" style="margin-bottom:1rem">
127 <input name="q" value="${escape(query)}" placeholder="Number, name or email" maxlength="80" style="max-width:22rem">
137 <input name="q" value="${escape(query)}" placeholder="Number, name, email or account" maxlength="80"
138 style="max-width:22rem">
128139 <button class="btn ghost">Search</button>
129140 </form>
130141 ${live("order-list", ORDER_EVENTS, orderRows(list))}
@@ -145,7 +156,7 @@ export function order(
145156 return `
146157 ${nav("orders")}
147158 <h1>${escape(found.number)} ${statusPill(found.status)}</h1>
148 <p class="lede">${escape(found.name)} · ${escape(found.email)} · ${escape(found.gateway)}${
159 <p class="lede">${escape(found.name)} · ${escape(found.email)} · ${account(found)} · ${escape(found.gateway)}${
149160 found.payment_ref ? ` · ${escape(found.payment_ref)}` : ""
150161 }</p>
151162 ${message ? `<p class="notice${message.bad ? " bad" : ""}">${escape(message.text)}</p>` : ""}
src/pages/cart.ts +15 −1
@@ -5,8 +5,10 @@
55
66 import { csrfField, escape } from "../html.ts";
77 import { formatCents } from "../money.ts";
8 import { FORGE_LINK_ATTRS, signInHref } from "../who.ts";
89 import type { Quote } from "../cart.ts";
910 import type { Session } from "../session.ts";
11 import type { Who } from "../who.ts";
1012
1113 const row = (label: string, value: string, className = ""): string =>
1214 `<div class="row ${className}"><span>${label}</span><span class="price">${value}</span></div>`;
@@ -65,7 +67,12 @@ export function codeBox(quote: Quote, session: Session, back: "cart" | "checkout
6567 </div>`;
6668 }
6769
68 export function page(quote: Quote, session: Session, message?: string): string {
70 /**
71 * The cart itself is open to anybody; buying is not, because an order belongs
72 * to an account. A shopper who is not signed in is told that here, with a full
73 * cart in front of them, rather than after they have typed an address.
74 */
75 export function page(quote: Quote, session: Session, who: Who, message?: string): string {
6976 if (quote.lines.length === 0)
7077 return `<h1>Your cart</h1><p class="lede">Nothing in it yet.</p><p><a class="btn" href="/">Look around</a></p>`;
7178 const rows = quote.lines
@@ -106,6 +113,13 @@ export function page(quote: Quote, session: Session, message?: string): string {
106113 return `
107114 <h1>Your cart</h1>
108115 ${message ? `<p class="notice">${escape(message)}</p>` : ""}
116 ${
117 who.role === "anon"
118 ? `<p class="notice">Buying needs an account — this cart does not.
119 <a ${FORGE_LINK_ATTRS} href="${escape(signInHref("/checkout"))}">Sign in to buy</a>
120 and your cart comes with you.</p>`
121 : ""
122 }
109123 ${quote.problems.concat(quote.code_problems).map((problem) => `<p class="notice bad">${escape(problem)}</p>`).join("\n")}
110124 <table>
111125 <thead><tr><th>Item</th><th class="num">Price</th><th>Quantity</th><th class="num">Line</th><th></th></tr></thead>
src/pages/checkout.ts +53 −25
@@ -9,6 +9,8 @@ import { HOME_COUNTRY } from "../tax.ts";
99 import { csrfField, escape } from "../html.ts";
1010 import { dueCents, formatCents } from "../money.ts";
1111 import { codeBox, totals } from "./cart.ts";
12 import { tokenQuery } from "../orderlink.ts";
13 import { FORGE_LINK_ATTRS, signInHref } from "../who.ts";
1214 import type { Quote } from "../cart.ts";
1315 import type { Order, OrderItem, Payment } from "../db.ts";
1416 import type { Shortfall } from "../orders.ts";
@@ -28,14 +30,22 @@ const field = (
2830 ${options.autocomplete ? `autocomplete="${escape(options.autocomplete)}"` : ""}>
2931 </label></div>`;
3032
31 export function checkout(
32 quote: Quote,
33 session: Session,
34 gateway: GatewayInfo,
35 idempotencyKey: string,
36 values: Record<string, string>,
37 error?: string,
38 ): string {
33 /**
34 * Checkout. An order belongs to an account, so a shopper the forge has not
35 * signed in is shown the same order summary with the address form replaced by
36 * one way forward: sign in, and come back to this page with this cart.
37 */
38 export function checkout(input: {
39 quote: Quote;
40 session: Session;
41 /** Who the forge says is buying; anon sees the invitation instead of the form. */
42 who: Who;
43 gateway: GatewayInfo;
44 idempotencyKey: string;
45 values: Record<string, string>;
46 error?: string;
47 }): string {
48 const { quote, session, who, gateway, values, error } = input;
3949 const summary = quote.lines
4050 .map(
4151 (line) =>
@@ -43,15 +53,21 @@ export function checkout(
4353 <td class="num">${line.quantity}</td><td class="num price">${escape(formatCents(line.line_total_cents))}</td></tr>`,
4454 )
4555 .join("\n");
46 return `
47 <h1>Checkout</h1>
48 ${error ? `<p class="notice bad">${escape(error)}</p>` : ""}
49 ${quote.problems.concat(quote.code_problems).map((problem) => `<p class="notice bad">${escape(problem)}</p>`).join("\n")}
50 <div class="product">
51 <form method="post" action="/checkout">
56 const left =
57 who.role === "anon"
58 ? `<div>
59 <h2 style="margin-top:0">Sign in to buy</h2>
60 <p>An order belongs to an account: it is how you find it again, and how this shop knows
61 whose it is. Browsing and this cart do not need one.</p>
62 <p><a class="btn" ${FORGE_LINK_ATTRS} href="${escape(signInHref("/checkout"))}">Sign in to buy</a></p>
63 <p class="muted" style="font-size:.88rem">Your cart is kept exactly as it is, and the address is
64 asked for on the way back.</p>
65 </div>`
66 : `<form method="post" action="/checkout">
5267 ${csrfField(session)}
53 <input type="hidden" name="idempotency" value="${escape(idempotencyKey)}">
68 <input type="hidden" name="idempotency" value="${escape(input.idempotencyKey)}">
5469 <h2 style="margin-top:0">Where it goes</h2>
70 <p class="muted" style="font-size:.88rem">This order will belong to <strong>${escape(who.name)}</strong>.</p>
5571 <div class="fields">
5672 ${field("email", "Email", values.email ?? "", { wide: true, type: "email", autocomplete: "email" })}
5773 ${field("name", "Full name", values.name ?? "", { wide: true, autocomplete: "name" })}
@@ -68,7 +84,13 @@ ${quote.problems.concat(quote.code_problems).map((problem) => `<p class="notice
6884 </div>
6985 <p class="muted" style="font-size:.88rem; margin-top:1rem">Payment is taken next, by ${escape(gateway.label)}.</p>
7086 <button class="btn" style="margin-top:.5rem">Continue to payment</button>
71 </form>
87 </form>`;
88 return `
89 <h1>Checkout</h1>
90 ${error ? `<p class="notice bad">${escape(error)}</p>` : ""}
91 ${quote.problems.concat(quote.code_problems).map((problem) => `<p class="notice bad">${escape(problem)}</p>`).join("\n")}
92 <div class="product">
93 ${left}
7294 <div>
7395 <h2 style="margin-top:0">Your order</h2>
7496 <table><tbody>${summary}</tbody></table>
@@ -221,6 +243,9 @@ export function confirmation(
221243 // A card's code is printed once, on the receipt. Say so here: a buyer who
222244 // closes this tab without opening it has bought something they cannot spend.
223245 const cards = items.reduce((count, item) => count + (item.gift_card_cents > 0 ? item.quantity : 0), 0);
246 // What every link away from here carries: nothing for the account this
247 // order opens for, the token for whoever it took a token to get here.
248 const key = tokenQuery(who, order);
224249 return `
225250 <h1>${paid ? "Thank you" : "Order " + escape(order.number)}</h1>
226251 <p class="lede">Order ${escape(order.number)} · ${statusPill(order.status)} · placed ${escape(order.created_at)} UTC</p>
@@ -229,22 +254,20 @@ ${
229254 ? shortPayment(order, short)
230255 : order.status === "pending_payment"
231256 ? `<p class="notice">This order is not paid yet.
232 <a href="/pay/${escape(order.number)}?token=${escape(order.access_token)}">Finish paying</a>.</p>`
257 <a href="/pay/${escape(order.number)}${escape(key)}">Finish paying</a>.</p>`
233258 : ""
234259 }
235260 ${
236261 paid && email
237262 ? `<p class="notice">A receipt was written to <strong>${escape(order.email)}</strong>.
238 <a href="/orders/${escape(order.number)}/receipt?token=${escape(order.access_token)}">Read it</a>
263 <a href="/orders/${escape(order.number)}/receipt${escape(key)}">Read it</a>
239264 — this store has no mail server, so receipts are kept here instead of sent.</p>`
240265 : ""
241266 }
242267 ${
243268 paid && cards > 0
244269 ? `<p class="notice">${cards === 1 ? "The gift card's code is" : `The ${cards} gift card codes are`} on the receipt,
245 and that is the only copy. <a href="/orders/${escape(order.number)}/receipt?token=${escape(
246 order.access_token,
247 )}">Open the receipt</a> and keep ${cards === 1 ? "it" : "them"} somewhere safe — whoever has the code can spend it,
270 and that is the only copy. <a href="/orders/${escape(order.number)}/receipt${escape(key)}">Open the receipt</a> and keep ${cards === 1 ? "it" : "them"} somewhere safe — whoever has the code can spend it,
248271 and <a href="/gift-cards">/gift-cards</a> says what is left on one.</p>`
249272 : ""
250273 }
@@ -252,14 +275,19 @@ ${itemsTable(items)}
252275 ${orderTotals(order, refunded)}
253276 <h2>Shipping to</h2>
254277 <p>${addressBlock(order)}<br><span class="muted">${escape(order.email)}</span></p>
255 <p class="muted">Keep this address — it is what opens this order. If you lose it,
256 <a href="/orders">find the order</a> again with its number and ${escape(order.email)}.
278 ${
279 order.buyer_id === ""
280 ? `<p class="muted">Keep this address — it is what opens this order. If you lose it,
281 <a href="/orders">find the order</a> again with its number and ${escape(order.email)}.`
282 : `<p class="muted">This order belongs to <strong>${escape(order.buyer_name)}</strong>: it is listed in
283 <a href="/orders">your orders</a> and opens there without a link to keep.`
284 }
257285 ${who.role === "admin" ? `<a href="/admin/orders/${escape(order.number)}">Open in admin</a>.` : ""}</p>
258286 `;
259287 }
260288
261289 /** The receipt, rendered as the email it would have been. */
262 export const receipt = (order: Order, email: Email): string => `
290 export const receipt = (order: Order, email: Email, who: Who): string => `
263291 <div class="receipt">
264292 <div class="head">
265293 <div><strong>From</strong> [email protected]</div>
@@ -270,6 +298,6 @@ export const receipt = (order: Order, email: Email): string => `
270298 <pre>${escape(email.body)}</pre>
271299 </div>
272300 <p style="text-align:center; margin-top:1.5rem">
273 <a class="btn ghost" href="/orders/${escape(order.number)}?token=${escape(order.access_token)}">Back to the order</a>
301 <a class="btn ghost" href="/orders/${escape(order.number)}${escape(tokenQuery(who, order))}">Back to the order</a>
274302 </p>
275303 `;
src/pages/find.ts deleted −66
@@ -1,66 +0,0 @@
1 /**
2 * "Find an order": the way back to an order once the link is gone.
3 *
4 * Two things are on this page — the orders this browser remembers placing,
5 * and a form that asks for an order number and the email it was placed with.
6 * Every link here carries the order's own access token, so the page that
7 * opens is the same one the shopper was sent to at checkout.
8 */
9
10 import { csrfField, escape } from "../html.ts";
11 import { formatCents } from "../money.ts";
12 import { statusPill } from "./checkout.ts";
13 import type { Order } from "../db.ts";
14 import type { Session } from "../session.ts";
15
16 const row = (order: Order): string => `<tr>
17 <td><a href="/orders/${escape(order.number)}?token=${escape(order.access_token)}">${escape(order.number)}</a></td>
18 <td class="muted">${escape(order.created_at)} UTC</td>
19 <td>${statusPill(order.status)}</td>
20 <td class="num price">${escape(formatCents(order.total_cents))}</td>
21 </tr>`;
22
23 export function find(
24 mine: Order[],
25 session: Session,
26 values: { number?: string; email?: string } = {},
27 error?: string,
28 ): string {
29 return `
30 <h1>Find an order</h1>
31 <p class="lede">An order is private, so it opens with its own link. Type the order number and the
32 email it was placed with and the link is handed back to you.</p>
33 ${error ? `<p class="notice bad">${escape(error)}</p>` : ""}
34 <form method="post" action="/orders/find" class="lookup">
35 ${csrfField(session)}
36 <div>
37 <label>Order number
38 <input name="number" value="${escape(values.number ?? "")}" maxlength="20" required
39 placeholder="BZ-XXXXXXXX" autocapitalize="characters" autocomplete="off">
40 </label>
41 </div>
42 <div>
43 <label>Email
44 <input name="email" type="email" value="${escape(values.email ?? "")}" maxlength="200" required
45 autocomplete="email">
46 </label>
47 <button class="btn" style="margin-top:.75rem">Find it</button>
48 </div>
49 </form>
50 ${
51 mine.length === 0
52 ? `<p class="muted" style="margin-top:2rem">No orders have been placed from this browser.</p>`
53 : `<h2 style="margin-top:2rem">From this browser</h2>
54 <table>
55 <thead><tr><th>Order</th><th>Placed</th><th>Status</th><th class="num">Total</th></tr></thead>
56 <tbody>${mine.map(row).join("\n")}</tbody>
57 </table>
58 <p class="muted">The last ${mine.length === 1 ? "order" : mine.length + " orders"} placed here, remembered in a
59 signed cookie that holds order numbers and nothing else. On a shared computer, drop it:</p>
60 <form method="post" action="/orders/forget">
61 ${csrfField(session)}
62 <button class="btn ghost">Forget these orders</button>
63 </form>`
64 }
65 `;
66 }
src/pages/orders.ts added +127
@@ -0,0 +1,127 @@
1 /**
2 * `/orders`: what an account has bought, and the way back to a guest order.
3 *
4 * An order belongs to the account that placed it, so an account's own orders
5 * are listed here and their links carry no token — the row says whose they
6 * are. A shopper the forge has not signed in is shown the door in, never a
7 * list: there is nothing here that is anybody's until somebody is somebody.
8 *
9 * Under that, whoever is asking, the guest orders this browser has found —
10 * rows from before an order had an account, which have nothing but their
11 * access token — and the form that finds one by its number and the email it
12 * was placed with. That half belongs to the browser rather than to an account,
13 * so an anonymous shopper sees it too: it is the only half that is theirs.
14 */
15
16 import { csrfField, escape } from "../html.ts";
17 import { formatCents } from "../money.ts";
18 import { statusPill } from "./checkout.ts";
19 import { FORGE_LINK_ATTRS, signInHref } from "../who.ts";
20 import type { Order } from "../db.ts";
21 import type { Session } from "../session.ts";
22 import type { Who } from "../who.ts";
23
24 /** An order this account owns: it opens for them, so the link needs nothing. */
25 const ownRow = (order: Order): string => `<tr>
26 <td><a href="/orders/${escape(order.number)}">${escape(order.number)}</a></td>
27 <td class="muted">${escape(order.created_at)} UTC</td>
28 <td>${statusPill(order.status)}</td>
29 <td class="num price">${escape(formatCents(order.total_cents))}</td>
30 </tr>`;
31
32 /** A guest order: only its own access token opens it, so the link carries one. */
33 const guestRow = (order: Order): string => `<tr>
34 <td><a href="/orders/${escape(order.number)}?token=${escape(order.access_token)}">${escape(order.number)}</a></td>
35 <td class="muted">${escape(order.created_at)} UTC</td>
36 <td>${statusPill(order.status)}</td>
37 <td class="num price">${escape(formatCents(order.total_cents))}</td>
38 </tr>`;
39
40 const table = (rows: string): string => `<table>
41 <thead><tr><th>Order</th><th>Placed</th><th>Status</th><th class="num">Total</th></tr></thead>
42 <tbody>${rows}</tbody>
43 </table>`;
44
45 /** The form that finds an order nobody's account can claim. */
46 const lookup = (session: Session, values: { number?: string; email?: string }): string => `
47 <form method="post" action="/orders/find" class="lookup">
48 ${csrfField(session)}
49 <div>
50 <label>Order number
51 <input name="number" value="${escape(values.number ?? "")}" maxlength="20" required
52 placeholder="BZ-XXXXXXXX" autocapitalize="characters" autocomplete="off">
53 </label>
54 </div>
55 <div>
56 <label>Email
57 <input name="email" type="email" value="${escape(values.email ?? "")}" maxlength="200" required
58 autocomplete="email">
59 </label>
60 <button class="btn" style="margin-top:.75rem">Find it</button>
61 </div>
62 </form>`;
63
64 /**
65 * The guest half, shown to whoever is holding the cookie — signed in or not.
66 * A guest order is found by a browser, not by an account, so the browser that
67 * found it has to be able to see the list it was added to and to drop it.
68 */
69 const guestHalf = (guests: Order[], session: Session): string =>
70 guests.length === 0
71 ? ""
72 : `<h2 style="margin-top:2rem">Guest orders found in this browser</h2>
73 <p class="muted">Placed before an order had an account, so an access token is all they have. They are
74 remembered in a signed cookie that holds order numbers and nothing else; on a shared computer, drop it:</p>
75 ${table(guests.map(guestRow).join("\n"))}
76 <form method="post" action="/orders/forget">
77 ${csrfField(session)}
78 <button class="btn ghost">Forget these orders</button>
79 </form>`;
80
81 const findHalf = (session: Session, values: { number?: string; email?: string }): string => `
82 <h2 style="margin-top:2rem">Find a guest order</h2>
83 <p class="muted">An order placed as a guest, before accounts, opens with its own link. Type the order
84 number and the email it was placed with and the link is handed back to you. An order bought with an
85 account is not found this way — sign in and it is listed above.</p>
86 ${lookup(session, values)}`;
87
88 export function page(input: {
89 who: Who;
90 /** The signed-in account's own orders, newest first. Empty for anon. */
91 mine: Order[];
92 /** Guest orders this browser has found: no account owns them. */
93 guests: Order[];
94 session: Session;
95 /** Where signing in should come back to. */
96 path: string;
97 values?: { number?: string; email?: string };
98 error?: string;
99 }): string {
100 const values = input.values ?? {};
101 const error = input.error ? `<p class="notice bad">${escape(input.error)}</p>` : "";
102
103 if (input.who.role === "anon")
104 return `
105 <h1>Your orders</h1>
106 <p class="lede">An order belongs to the account that placed it. Sign in and the orders you have
107 bought are listed here, each opening without a link to keep.</p>
108 ${error}
109 <p><a class="btn" ${FORGE_LINK_ATTRS} href="${escape(signInHref(input.path))}">Sign in to see your orders</a></p>
110 ${guestHalf(input.guests, input.session)}
111 ${findHalf(input.session, values)}
112 `;
113
114 return `
115 <h1>Your orders</h1>
116 <p class="lede">The orders on <strong>${escape(input.who.name)}</strong>, newest first. Each one opens
117 for this account — there is no link to keep.</p>
118 ${error}
119 ${
120 input.mine.length === 0
121 ? `<p class="muted">Nothing bought on this account yet.</p><p><a class="btn" href="/">Look around</a></p>`
122 : table(input.mine.map(ownRow).join("\n"))
123 }
124 ${guestHalf(input.guests, input.session)}
125 ${findHalf(input.session, values)}
126 `;
127 }
src/routes.ts +11 −1
@@ -41,7 +41,7 @@ export const routes: Route[] = [
4141 { method: "POST", pattern: "/pay/:number/stripe", handler: shop.payStripe },
4242 { method: "POST", pattern: "/webhooks/stripe", handler: shop.stripeWebhook, csrf: false, raw: true },
4343
44 { method: "GET", pattern: "/orders", handler: (ctx) => shop.findForm(ctx) },
44 { method: "GET", pattern: "/orders", handler: (ctx) => shop.ordersPage(ctx) },
4545 { method: "POST", pattern: "/orders/find", handler: shop.findOrder },
4646 { method: "POST", pattern: "/orders/forget", handler: shop.forgetOrders },
4747 { method: "GET", pattern: "/orders/:number", handler: shop.orderPage },
@@ -79,6 +79,16 @@ export const routes: Route[] = [
7979
8080 export type Match = { route: Route; params: Record<string, string> };
8181
82 /**
83 * True when a GET of this route's own path renders a page. A POST-only
84 * pattern is not one — `/orders/find` is answered by `GET /orders/:number`,
85 * which is a 404 for an order called "find" — so the pattern has to be listed
86 * for GET itself, not merely be shaped like something that is. The front door
87 * asks this to work out where signing in comes back to.
88 */
89 export const getAnswers = (pattern: string): boolean =>
90 routes.some((route) => route.method === "GET" && route.pattern === pattern);
91
8292 /** A path segment a browser mangled is still a string, not a crash. */
8393 const decodeOrRaw = (segment: string): string => {
8494 try {
src/server.test.ts +321 −79
@@ -35,6 +35,11 @@ class Shopper {
3535 /** Who the forge says this browser is; undefined is a signed-out visitor. */
3636 constructor(private person?: Person) {}
3737
38 /** The same browser, and the same cookies, once the forge has signed it in. */
39 signIn(person: Person): void {
40 this.person = person;
41 }
42
3843 private forge(): Record<string, string> {
3944 return identityHeaders(this.person);
4045 }
@@ -97,6 +102,17 @@ class Shopper {
97102 /** An admin, the way the forge signs one in. */
98103 const asAdmin = (): Shopper => new Shopper({ id: "u_1", name: "ada", role: "admin" });
99104
105 /**
106 * A shopper with an account, because buying needs one: an order belongs to
107 * the account that placed it. Each call is a different person.
108 */
109 let accounts = 0;
110 const account = (): Person => {
111 accounts += 1;
112 return { id: `u_shopper_${accounts}`, name: `shopper${accounts}`, role: "user" };
113 };
114 const asCustomer = (): Shopper => new Shopper(account());
115
100116 const variantId = (sku: string): number =>
101117 db.query<{ id: number }, [string]>("SELECT id FROM variant WHERE sku = ?1").get(sku)!.id;
102118 const stockOf = (sku: string): number =>
@@ -344,7 +360,7 @@ test("a cart cookie from somewhere else is ignored", async () => {
344360 test("checkout refuses an address it cannot use", async () => {
345361 const orders = (): number => db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM "order"').get()!.n;
346362 const before = orders();
347 const shopper = new Shopper();
363 const shopper = asCustomer();
348364 const product = await shopper.page("/p/house-espresso");
349365 await shopper.post(
350366 "/cart/add",
@@ -371,7 +387,7 @@ test("checkout refuses an address it cannot use", async () => {
371387 });
372388
373389 test("a purchase takes stock, records a charge and writes a receipt", async () => {
374 const shopper = new Shopper();
390 const shopper = asCustomer();
375391 const before = stockOf("COF-GUJI-250");
376392 const { number, token } = await buy(shopper, "COF-GUJI-250", 2);
377393 expect(stockOf("COF-GUJI-250")).toBe(before - 2);
@@ -403,7 +419,7 @@ test("a purchase takes stock, records a charge and writes a receipt", async () =
403419
404420 test("a discount, a part-spent gift card and a state's rate become an order that adds up", async () => {
405421 limit.forget();
406 const shopper = new Shopper();
422 const shopper = asCustomer();
407423 // A card someone has already spent $15 of, so $35 of its $50 is left.
408424 const { card, code } = giftcards.issue({ cents: 5000, note: "bought earlier" });
409425 expect(giftcards.spend(card.id, 1500, "spent at another till")).toBe(true);
@@ -487,8 +503,8 @@ test("two orders on one gift card spend what is on it and no more", async () =>
487503 const { card, code } = giftcards.issue({ cents: 3000, note: "one card, two shoppers" });
488504
489505 // Two shoppers hold the same code, each with $23.32 to pay.
490 const first = new Shopper();
491 const second = new Shopper();
506 const first = asCustomer();
507 const second = asCustomer();
492508 for (const shopper of [first, second]) {
493509 await addToCart(shopper, "COF-HOUSE-250", 1);
494510 expect((await applyCode(shopper, code)).headers.get("location")).toContain("m=applied");
@@ -539,16 +555,156 @@ test("two orders on one gift card spend what is on it and no more", async () =>
539555 expect(giftcards.byId(card.id)!.balance_cents).toBe(0);
540556 });
541557
542 test("an order is only reached with its own token", async () => {
543 const shopper = new Shopper();
558 test("an order opens for its buyer with no token, and for nobody else without one", async () => {
559 const shopper = asCustomer();
544560 const { number, token } = await buy(shopper, "COF-HOUSE-250", 1);
545 expect((await shopper.get(`/orders/${number}`)).status).toBe(404);
546 expect((await shopper.get(`/orders/${number}?token=wrong`)).status).toBe(404);
547 expect((await shopper.get(`/orders/${number}?token=${encodeURIComponent(token)}`)).status).toBe(200);
561
562 // Its buyer needs nothing: the row says whose it is.
563 expect((await shopper.get(`/orders/${number}`)).status).toBe(200);
564 expect((await shopper.get(`/orders/${number}/receipt`)).status).toBe(200);
565 expect(await shopper.page(`/orders/${number}`)).toContain(number);
566
567 // A second account is a stranger, signed in or not, and so is a bad token.
568 const other = asCustomer();
569 expect((await other.get(`/orders/${number}`)).status).toBe(404);
570 expect((await other.get(`/orders/${number}/receipt`)).status).toBe(404);
571 expect((await other.get(`/pay/${number}`)).status).toBe(404);
572 expect((await new Shopper().get(`/orders/${number}`)).status).toBe(404);
573 expect((await shopper.get(`/orders/${number}?token=wrong`)).status).toBe(200); // still theirs
574 expect((await other.get(`/orders/${number}?token=wrong`)).status).toBe(404);
575
576 // The token still opens it for whoever holds the link, which is what the
577 // receipt and Stripe's return URL carry.
578 expect((await new Shopper().get(`/orders/${number}?token=${encodeURIComponent(token)}`)).status).toBe(200);
579 expect((await other.get(`/orders/${number}?token=${encodeURIComponent(token)}`)).status).toBe(200);
580
581 // And an admin keeps their way in.
582 expect((await asAdmin().get(`/orders/${number}`)).status).toBe(200);
583 });
584
585 test("an order belongs to the account that placed it", async () => {
586 const shopper = asCustomer();
587 const { number } = await buy(shopper, "COF-HOUSE-250", 1);
588 const order = orderNamed(number);
589 expect(order.buyer_id).toMatch(/^u_shopper_\d+$/);
590 expect(order.buyer_name).toMatch(/^shopper\d+$/);
591 // The email is the address the shopper typed, never the account's name.
592 expect(order.email).toBe("[email protected]");
593 expect(await asAdmin().page(`/admin/orders/${number}`)).toContain(`account ${order.buyer_name}`);
594 expect(await asAdmin().page("/admin/orders")).toContain(`account ${order.buyer_name}`);
595 });
596
597 test("a cart filled as a guest becomes that account's order once they sign in", async () => {
598 limit.forget();
599 const shopper = new Shopper();
600 await addToCart(shopper, "COF-GUJI-250", 1);
601 expect((await applyCode(shopper, "welcome10")).headers.get("location")).toContain("m=applied");
602
603 // Checkout shows the same order summary, and one way forward.
604 const invited = await shopper.page("/checkout");
605 expect(invited).toContain("Sign in to buy");
606 expect(invited).toContain('href="/__forge/signin?to=%2Fcheckout"');
607 expect(invited).toContain("Ethiopia Guji");
608 expect(invited).not.toContain('name="address1"');
609 expect(await shopper.page("/cart")).toContain("Buying needs an account");
610
611 // And a POST writes nothing at all: no order, no stock taken, no code used.
612 const orders = () => db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM "order"').get()!.n;
613 const used = () => db.query<any, []>("SELECT used_count FROM discount WHERE code = 'WELCOME10'").get()!.used_count;
614 const before = { orders: orders(), stock: stockOf("COF-GUJI-250"), used: used() };
615 const refused = await shopper.post(
616 "/checkout",
617 { ...ADDRESS, idempotency: "anything" },
618 Shopper.tokenIn(invited),
619 );
620 expect(refused.status).toBe(303);
621 expect(refused.headers.get("location")).toBe("/checkout");
622 expect(orders()).toBe(before.orders);
623 expect(stockOf("COF-GUJI-250")).toBe(before.stock);
624 expect(used()).toBe(before.used);
625
626 // The same browser, the same cart, once the forge has signed it in.
627 const person = account();
628 shopper.signIn(person);
629 const form = await shopper.page("/checkout");
630 expect(form).toContain('name="address1"');
631 expect(form).toContain(person.name);
632 expect(form).toContain("Ethiopia Guji");
633 const placed = await submitCheckout(shopper);
634 expect(placed.status).toBe(303);
635 const { number } = orderAt(placed.headers.get("location")!);
636 expect(orderNamed(number).buyer_id).toBe(person.id);
637 expect(stockOf("COF-GUJI-250")).toBe(before.stock - 1);
638 limit.forget();
639 });
640
641 test("a guest order from before accounts keeps its story: the token, and the form", async () => {
642 limit.forget();
643 // A row as it was written before an order had an account: no buyer at all.
644 const legacy = db
645 .query<any, []>(
646 `INSERT INTO "order" (number, access_token, email, name, address1, city, postal, country,
647 subtotal_cents, shipping_cents, tax_cents, total_cents, idempotency_key, status)
648 VALUES ('BZ-LEGACY01', 'legacy-token-abc', '[email protected]', 'Old Guest', '9 Old Road', 'Testville',
649 '12345', 'United States', 1000, 600, 0, 1600, 'legacy-key', 'paid')
650 RETURNING *`,
651 )
652 .get()!;
653 expect(legacy.buyer_id).toBe("");
654 expect(legacy.buyer_name).toBe("");
655
656 const guest = new Shopper();
657 expect((await guest.get("/orders/BZ-LEGACY01")).status).toBe(404);
658 const opened = await guest.get(`/orders/BZ-LEGACY01?token=${legacy.access_token}`);
659 expect(opened.status).toBe(200);
660 expect(await opened.text()).toContain("BZ-LEGACY01");
661
662 // An account that finds it by number and email sees it listed as a guest
663 // order — and can drop it, because a shared browser is not a shopper's.
664 const shopper = asCustomer();
665 const form = await shopper.page("/orders");
666 const found = await shopper.post(
667 "/orders/find",
668 { number: "BZ-LEGACY01", email: "[email protected]" },
669 Shopper.tokenIn(form),
670 );
671 expect(found.status).toBe(303);
672 expect(found.headers.get("location")).toContain("token=");
673 const listed = await shopper.page("/orders");
674 expect(listed).toContain("BZ-LEGACY01");
675 expect(listed).toContain("Guest orders found in this browser");
676
677 const forgotten = await shopper.post("/orders/forget", {}, Shopper.tokenIn(listed));
678 expect(forgotten.status).toBe(303);
679 expect(await shopper.page("/orders")).not.toContain("BZ-LEGACY01");
680 // Forgetting is a POST from this page and nowhere else.
681 expect((await shopper.post("/orders/forget", {})).status).toBe(403);
682
683 // The same door for a browser the forge has not signed in, because a guest
684 // order is a browser's and not an account's: what the cookie was written
685 // with is a list they can see, and a list they can drop. A cookie nobody can
686 // read is worse than no cookie at all.
687 const anon = new Shopper();
688 const invitation = await anon.page("/orders");
689 expect(invitation).toContain("Sign in to see your orders");
690 expect(invitation).not.toContain("Guest orders found in this browser");
691 const handed = await anon.post(
692 "/orders/find",
693 { number: "BZ-LEGACY01", email: "[email protected]" },
694 Shopper.tokenIn(invitation),
695 );
696 expect(handed.status).toBe(303);
697 const anonList = await anon.page("/orders");
698 expect(anonList).toContain("BZ-LEGACY01");
699 expect(anonList).toContain("Guest orders found in this browser");
700 const dropped = await anon.post("/orders/forget", {}, Shopper.tokenIn(anonList));
701 expect(dropped.status).toBe(303);
702 expect(await anon.page("/orders")).not.toContain("BZ-LEGACY01");
703 limit.forget();
548704 });
549705
550706 test("a declined payment leaves the order unpaid and charges nothing", async () => {
551 const shopper = new Shopper();
707 const shopper = asCustomer();
552708 const product = await shopper.page("/p/stoneware-mug");
553709 await shopper.post("/cart/add", { variant: String(variantId("KIT-MUG-OAT")), quantity: "1" }, Shopper.tokenIn(product));
554710 const checkout = await shopper.page("/checkout");
@@ -581,7 +737,7 @@ test("a declined payment leaves the order unpaid and charges nothing", async ()
581737 });
582738
583739 test("the same checkout form submitted twice places one order", async () => {
584 const shopper = new Shopper();
740 const shopper = asCustomer();
585741 const product = await shopper.page("/p/paper-filters");
586742 await shopper.post("/cart/add", { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, Shopper.tokenIn(product));
587743 const checkout = await shopper.page("/checkout");
@@ -604,7 +760,7 @@ test("the same checkout form submitted twice places one order", async () => {
604760 });
605761
606762 test("something sold out cannot be bought", async () => {
607 const shopper = new Shopper();
763 const shopper = asCustomer();
608764 const product = await shopper.page("/p/stoneware-mug");
609765 expect(product).toContain("sold out"); // KIT-MUG-RUST is seeded at zero
610766 const added = await shopper.post(
@@ -637,7 +793,7 @@ test("something sold out cannot be bought", async () => {
637793
638794 test("checkout is rate limited", async () => {
639795 limit.forget();
640 const shopper = new Shopper();
796 const shopper = asCustomer();
641797 const product = await shopper.page("/p/paper-filters");
642798 await shopper.post("/cart/add", { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, Shopper.tokenIn(product));
643799 const checkout = await shopper.page("/checkout");
@@ -678,7 +834,9 @@ test("the admin form refuses an expiry that is not a date, on the page as at the
678834
679835 test("the checkout bucket is the whole shop's, and it refills", async () => {
680836 limit.forget();
681 const first = new Shopper();
837 // Both browsers are signed in: an anon POST /checkout never reaches the till,
838 // so a bucket emptied by one of those would prove nothing about the till.
839 const first = asCustomer();
682840 const product = await first.page("/p/paper-filters");
683841 await first.post("/cart/add", { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, Shopper.tokenIn(product));
684842 const token = Shopper.tokenIn(await first.page("/checkout"));
@@ -692,7 +850,7 @@ test("the checkout bucket is the whole shop's, and it refills", async () => {
692850 // keyed on that and nothing else, so an emptied till is emptied for the next
693851 // shopper too. That is the trade the capacity is set for: thirty checkouts a
694852 // minute is a shop's minute, which ordinary trade never spends.
695 const second = new Shopper();
853 const second = asCustomer();
696854 const theirs = await second.page("/p/paper-filters");
697855 await second.post("/cart/add", { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, Shopper.tokenIn(theirs));
698856 const checkout = await second.page("/checkout");
@@ -720,7 +878,7 @@ test("an unsigned webhook is refused", async () => {
720878
721879 test("/admin opens for an admin the forge signed, and refunds and restocks", async () => {
722880 limit.forget();
723 const shopper = new Shopper();
881 const shopper = asCustomer();
724882 const { number } = await buy(shopper, "COF-GUJI-1000", 1);
725883
726884 const admin = asAdmin();
@@ -777,34 +935,109 @@ test("no link to the forge carries a referrer, so the page's query stays here",
777935 });
778936
779937 test("signing in from a page the token opened lands somewhere that answers", async () => {
938 limit.forget();
780939 // Mid-purchase on /pay/:number?token=…: the token is deliberately kept out
781940 // of to=, so the return has to be a page that opens without it.
782 const shopper = new Shopper();
783 await addToCart(shopper, "COF-HOUSE-250", 1);
784 const placed = await submitCheckout(shopper);
941 const person = account();
942 const buyer = new Shopper(person);
943 await addToCart(buyer, "COF-HOUSE-250", 1);
944 const placed = await submitCheckout(buyer);
785945 const { number, token } = orderAt(placed.headers.get("location")!);
786946
787 /** Open a page with its token, follow the header's "Sign in", come back. */
788 const throughTheHeader = async (path: string): Promise<void> => {
789 const page = await shopper.page(path);
947 /**
948 * A browser holding the link and no account: open the page with its token,
949 * follow the header's "Sign in", and come back as `whom` to a page that
950 * answers with `listed` on it.
951 */
952 const throughTheHeader = async (path: string, whom: Person, listed: string): Promise<void> => {
953 const holder = new Shopper();
954 const page = await holder.page(path);
790955 expect(page).toContain("Sign in");
791956 const to = decodeURIComponent(/\/__forge\/signin\?to=([^"]+)"/.exec(page)![1]!);
792957 expect(to).not.toContain(token);
793958
794959 // the forge signs them in and sends the same browser on to `to=`
795 const signedIn = new Shopper({ id: "u_9", name: "cy", role: "user" });
796 signedIn.jar = shopper.jar;
797 const landed = await signedIn.get(to);
960 holder.signIn(whom);
961 const landed = await holder.get(to);
798962 expect(landed.status).toBe(200);
799 // and the order is one click from where they land: this browser's orders
800 // are listed there, each with its own token link
801 expect(await landed.text()).toContain(`/orders/${number}?token=${token}`);
963 expect(await landed.text()).toContain(listed);
802964 };
803965
804 await throughTheHeader(`/pay/${number}?token=${encodeURIComponent(token)}`);
805 await throughTheHeader(`/orders/${number}?token=${encodeURIComponent(token)}`);
806 expect((await payMock(shopper, number, token)).status).toBe(303);
807 await throughTheHeader(`/orders/${number}/receipt?token=${encodeURIComponent(token)}`);
966 // The buyer, on a browser the forge has not signed them into yet: they land
967 // on their own orders, and this one opens for them with no token at all.
968 await throughTheHeader(`/pay/${number}?token=${encodeURIComponent(token)}`, person, `href="/orders/${number}"`);
969
970 // The same order as a row from before accounts — nothing but its token —
971 // which is now the only way to be part-way through paying with no account.
972 db.query('UPDATE "order" SET buyer_id = \'\', buyer_name = \'\' WHERE number = ?1').run(number);
973 const stranger: Person = { id: "u_9", name: "cy", role: "user" };
974 // The order is one click from where they land: the guest orders this
975 // browser found are listed there, each with its own token link.
976 const guestLink = `/orders/${number}?token=${token}`;
977 await throughTheHeader(`/pay/${number}?token=${encodeURIComponent(token)}`, stranger, guestLink);
978 await throughTheHeader(`/orders/${number}?token=${encodeURIComponent(token)}`, stranger, guestLink);
979 expect((await payMock(new Shopper(), number, token)).status).toBe(303);
980 await throughTheHeader(`/orders/${number}/receipt?token=${encodeURIComponent(token)}`, stranger, guestLink);
981 limit.forget();
982 });
983
984 test("a missed order lookup still offers a way in that answers", async () => {
985 limit.forget();
986 // POST /orders/find renders "Your orders" when the lookup misses, and a GET
987 // of /orders/find is a 404: the sign-in offered on that page must not point
988 // at the page it is standing on.
989 const guest = new Shopper();
990 const page = await guest.page("/orders");
991 const missed = await guest.post(
992 "/orders/find",
993 { number: "BZ-NOTHERE", email: "[email protected]" },
994 Shopper.tokenIn(page),
995 );
996 expect(missed.status).toBe(200);
997 const body = await missed.text();
998 expect(body).toContain("No guest order here matches");
999 expect(body).not.toContain("to=%2Forders%2Ffind");
1000 // both the page's button and the header's link, and they are the same one
1001 expect([...body.matchAll(/\/__forge\/signin\?to=([^"]+)"/g)].map((found) => found[1])).toEqual([
1002 "%2Forders",
1003 "%2Forders",
1004 ]);
1005 expect((await guest.get("/orders")).status).toBe(200);
1006 expect((await guest.get("/orders/find")).status).toBe(404);
1007 limit.forget();
1008 });
1009
1010 test("the token stays out of the URL of a buyer the order opens for anyway", async () => {
1011 limit.forget();
1012 const person = account();
1013 const buyer = new Shopper(person);
1014 await addToCart(buyer, "COF-GUJI-250", 1);
1015 const placed = await submitCheckout(buyer);
1016 const { number, token } = orderAt(placed.headers.get("location")!);
1017
1018 // The buyer, signed in, opens the payment page by account — no token in the
1019 // query — and approving lands them on the order with none either.
1020 const payPage = await buyer.page(`/pay/${number}`);
1021 const paid = await buyer.post(`/pay/${number}/mock`, { token, decision: "approve" }, Shopper.tokenIn(payPage));
1022 expect(paid.headers.get("location")).toBe(`/orders/${number}`);
1023 // and the order's own page links on without handing them their token back
1024 const confirmation = await buyer.page(`/orders/${number}`);
1025 expect(confirmation).toContain(`href="/orders/${number}/receipt"`);
1026 expect(confirmation).not.toContain(token);
1027 expect(await buyer.page(`/orders/${number}/receipt`)).toContain(`href="/orders/${number}"`);
1028
1029 // A browser holding nothing but the token is here because of the token, so
1030 // every link it is handed still carries one.
1031 const holder = new Shopper();
1032 const held = await holder.page(`/orders/${number}?token=${encodeURIComponent(token)}`);
1033 expect(held).toContain(`/orders/${number}/receipt?token=${token}`);
1034 // pending again, so the "not paid yet" link is the one on the page
1035 db.query('UPDATE "order" SET status = \'pending_payment\' WHERE number = ?1').run(number);
1036 expect(await holder.page(`/orders/${number}?token=${encodeURIComponent(token)}`)).toContain(
1037 `/pay/${number}?token=${token}`,
1038 );
1039 expect(await buyer.page(`/orders/${number}`)).toContain(`href="/pay/${number}"`);
1040 limit.forget();
8081041 });
8091042
8101043 test("/admin is 403 and a sign-in link for everybody the forge did not sign as an admin", async () => {
@@ -1042,43 +1275,56 @@ test("a long ledger says it was cut off, and a void card offers nothing to spend
10421275 expect(full).not.toContain("The last 20 movements");
10431276 limit.forget();
10441277 });
1045 test("an order is found again with its number and the email it was placed with", async () => {
1278 test("the find form is the guest door only: an account's order is not handed out", async () => {
10461279 limit.forget();
1047 const shopper = new Shopper();
1280 const shopper = asCustomer();
10481281 const { number, token } = await buy(shopper, "COF-GUJI-250", 1);
1282 const id = orderNamed(number).id;
1283 const trail = (): string[] =>
1284 db
1285 .query<{ kind: string }, [number]>("SELECT kind FROM order_event WHERE order_id = ?1")
1286 .all(id)
1287 .map((row) => row.kind);
10491288
1050 // Another browser knows nothing until it names both the order and the email.
1289 // Another browser knows the number and the email — everything the form asks
1290 // for — and this order has an owner, so the form is not its door: the words
1291 // are a miss's, no token is handed back, and nothing is written to its trail.
10511292 const stranger = new Shopper();
10521293 expect((await stranger.get(`/orders/${number}`)).status).toBe(404);
10531294 const form = await stranger.page("/orders");
1054 const wrong = await stranger.post(
1055 "/orders/find",
1056 { number, email: "someone-else@example.com" },
1057 Shopper.tokenIn(form),
1058 );
1059 expect(wrong.status).toBe(200);
1060 expect(await wrong.text()).toContain("No order here matches");
1295 const before = trail();
1296 const tried = await stranger.post("/orders/find", { number, email: "[email protected]" }, Shopper.tokenIn(form));
1297 expect(tried.status).toBe(200);
1298 const said = await tried.text();
1299 expect(said).toContain("No guest order here matches");
1300 expect(said).not.toContain(token);
1301 expect(trail()).toEqual(before);
1302 expect(trail()).not.toContain("looked-up");
10611303
1062 const found = await stranger.post("/orders/find", { number, email: "[email protected]" }, Shopper.tokenIn(form));
1063 expect(found.status).toBe(303);
1064 const where = orderAt(found.headers.get("location")!);
1065 expect(where.number).toBe(number);
1066 expect(where.token).toBe(token);
1067 expect(await stranger.page(found.headers.get("location")!)).toContain(number);
1304 // The two ways in that the order does have are untouched: its own token,
1305 // and the account it belongs to.
1306 expect((await stranger.get(`/orders/${number}?token=${encodeURIComponent(token)}`)).status).toBe(200);
1307 expect(await shopper.page("/orders")).toContain(number);
10681308 limit.forget();
10691309 });
10701310
1071 test("a number that was never issued is refused in the same words as a wrong email", async () => {
1311 test("a number that was never issued, a wrong email and an owned order are refused in the same words", async () => {
10721312 limit.forget();
1073 const shopper = new Shopper();
1313 const shopper = asCustomer();
10741314 const form = await shopper.page("/orders");
10751315 const refusal = async (fields: Record<string, string>): Promise<string> =>
10761316 (await shopper.post("/orders/find", fields, Shopper.tokenIn(form))).text();
10771317 const invented = await refusal({ number: "BZ-ZZZZZZZZ", email: "[email protected]" });
10781318 const { number } = await buy(shopper, "COF-HOUSE-250", 1);
10791319 const mismatched = await refusal({ number, email: "[email protected]" });
1080 expect(invented).toContain("No order here matches");
1081 expect(mismatched).toContain("No order here matches");
1320 // The right pair on an order that belongs to an account: the same words, so
1321 // the form says nothing about which numbers are real or which are owned.
1322 const owned = await refusal({ number, email: "[email protected]" });
1323 const words = "No guest order here matches that number and email address.";
1324 expect(invented).toContain(words);
1325 expect(mismatched).toContain(words);
1326 expect(owned).toContain(words);
1327 expect(owned).not.toContain(orderNamed(number).access_token);
10821328 limit.forget();
10831329 });
10841330
@@ -1120,14 +1366,27 @@ test("a spent limit cannot be refilled by changing x-forwarded-for", async () =>
11201366 limit.forget();
11211367 });
11221368
1123 test("the orders a browser placed are listed at /orders, and only for that browser", async () => {
1369 test("/orders lists an account's own orders, and shows nobody else's", async () => {
11241370 limit.forget();
1125 const shopper = new Shopper();
1126 const { number, token } = await buy(shopper, "COF-GUJI-250", 1);
1371 const shopper = asCustomer();
1372 const { number } = await buy(shopper, "COF-GUJI-250", 1);
11271373 const mine = await shopper.page("/orders");
11281374 expect(mine).toContain(number);
1129 expect(mine).toContain(`/orders/${number}?token=${token}`);
1130 expect(await new Shopper().page("/orders")).not.toContain(number);
1375 // Their own order opens for them, so the link carries no token.
1376 expect(mine).toContain(`href="/orders/${number}"`);
1377 expect(mine).not.toContain("token=");
1378
1379 // Another account's list is their own, and empty.
1380 const other = asCustomer();
1381 const theirs = await other.page("/orders");
1382 expect(theirs).not.toContain(number);
1383 expect(theirs).toContain("Nothing bought on this account yet");
1384
1385 // And a shopper the forge has not signed in is offered the door, not a list.
1386 const anon = await new Shopper().page("/orders");
1387 expect(anon).not.toContain(number);
1388 expect(anon).toContain("Sign in to see your orders");
1389 expect(anon).toContain("/__forge/signin?to=%2Forders");
11311390 limit.forget();
11321391 });
11331392
@@ -1138,23 +1397,6 @@ test("the order-lookup form has a class of its own, not the header search's", as
11381397 expect(page.match(/class="find"/g)?.length).toBe(1);
11391398 });
11401399
1141 test("a shared browser can forget the orders it remembers", async () => {
1142 limit.forget();
1143 const shopper = new Shopper();
1144 const { number } = await buy(shopper, "COF-GUJI-250", 1);
1145 const listed = await shopper.page("/orders");
1146 expect(listed).toContain(number);
1147
1148 const forgotten = await shopper.post("/orders/forget", {}, Shopper.tokenIn(listed));
1149 expect(forgotten.status).toBe(303);
1150 const after = await shopper.page("/orders");
1151 expect(after).not.toContain(number);
1152 expect(after).toContain("No orders have been placed from this browser");
1153 // Forgetting is a POST from this page and nowhere else.
1154 expect((await shopper.post("/orders/forget", {})).status).toBe(403);
1155 limit.forget();
1156 });
1157
11581400 test("a variant that is not the named product's own is not added to the cart", async () => {
11591401 const shopper = new Shopper();
11601402 const product = await shopper.page("/p/stoneware-mug");
@@ -1212,7 +1454,7 @@ test("the rate the cart estimates is the rate the order charges, from one table"
12121454 tax.setDefaultRateBp(2000);
12131455 tax.upsertRate({ country: tax.HOME_COUNTRY, region: "", rate_bp: 1000, label: "US sales tax", active: true });
12141456 try {
1215 const shopper = new Shopper();
1457 const shopper = asCustomer(); // the order at the end of it needs an account
12161458 await addToCart(shopper, "COF-GUJI-250", 2); // $38.00 of goods
12171459
12181460 const cart = await shopper.page("/cart");
@@ -1417,13 +1659,13 @@ const watch = async (stop: AbortController): Promise<Response> => {
14171659 return fetch(`${base}/admin/events?since=${here.id}`, { headers: ADMIN_HEADERS(), signal: stop.signal });
14181660 };
14191661
1420 test("an admin watching the stream hears an order a stranger places", async () => {
1662 test("an admin watching the stream hears an order as a shopper places it", async () => {
14211663 const stop = new AbortController();
14221664 const stream = await watch(stop);
14231665 expect(stream.status).toBe(200);
14241666 expect(stream.headers.get("content-type")).toBe("text/event-stream");
14251667
1426 const shopper = new Shopper();
1668 const shopper = asCustomer();
14271669 await addToCart(shopper, "COF-GUJI-250", 1);
14281670 const placed = await submitCheckout(shopper, { email: "[email protected]" });
14291671 expect(placed.status).toBe(303);
@@ -1451,7 +1693,7 @@ test("an admin watching the stream hears an order a stranger places", async () =
14511693 });
14521694
14531695 test("paying and refunding an order reach the stream, once each", async () => {
1454 const shopper = new Shopper();
1696 const shopper = asCustomer();
14551697 await addToCart(shopper, "COF-HOUSE-250", 1);
14561698 const placed = await submitCheckout(shopper, { email: "[email protected]" });
14571699 const { number, token } = orderAt(placed.headers.get("location")!);
src/server.ts +12 −4
@@ -24,14 +24,14 @@ import {
2424 useSecret,
2525 } from "./session.ts";
2626 import { loadSecret } from "./sign.ts";
27 import { match } from "./routes.ts";
27 import { getAnswers, match } from "./routes.ts";
2828 import { notFound } from "./shop.ts";
2929 import { peerOf, trustedHops } from "./peer.ts";
3030 import { seed } from "./catalog.ts";
3131 import { seed as seedDiscounts } from "./discounts.ts";
3232 import { seed as seedTax } from "./tax.ts";
3333 import { HAND_OFF, readForm, readText, text } from "./http.ts";
34 import { whoIs } from "./who.ts";
34 import { returnTo, whoIs } from "./who.ts";
3535 import type { Who } from "./who.ts";
3636 import type { Ctx } from "./context.ts";
3737
@@ -138,6 +138,12 @@ export const server = Bun.serve({
138138 if (method === "POST" && !sameOrigin(request)) return finish(text("forbidden", 403), []);
139139
140140 const found = match(method, url.pathname);
141 // Where a sign-in on this page comes back to, decided once and for every
142 // page: this URL when a GET of it renders a page, and a page that does
143 // when it does not. A POST-only path — a missed order lookup, a declined
144 // mock payment — renders a page like any other, and that page must not
145 // offer a way in that lands on a 404.
146 const backTo = returnTo(url, found !== null && getAnswers(found.route.pattern));
141147 const session = loadSession(cookies[SESSION_COOKIE]);
142148 // Identity is the forge's to assert and ours to verify; the cookie above
143149 // only carries the CSRF token.
@@ -146,7 +152,7 @@ export const server = Bun.serve({
146152 outgoing.push(cookie(SESSION_COOKIE, session.sid, { maxAge: 60 * 60 * 24 * 30, secure: publicOrigin.startsWith("https:") }));
147153
148154 if (!found) {
149 const ctx = context(request, url, {}, session, who, null, null, cookies, outgoing, self);
155 const ctx = context(request, url, {}, session, who, backTo, null, null, cookies, outgoing, self);
150156 return finish(notFound(ctx), outgoing);
151157 }
152158
@@ -173,7 +179,7 @@ export const server = Bun.serve({
173179 return finish(text("that form was not from here — go back and try again", 403), outgoing);
174180 }
175181
176 const ctx = context(request, url, found.params, session, who, form, body, cookies, outgoing, self);
182 const ctx = context(request, url, found.params, session, who, backTo, form, body, cookies, outgoing, self);
177183 try {
178184 const response = await found.route.handler(ctx);
179185 return finish(request.method === "HEAD" ? new Response(null, response) : response, outgoing);
@@ -190,6 +196,7 @@ function context(
190196 params: Record<string, string>,
191197 session: ReturnType<typeof loadSession>,
192198 who: Who,
199 backTo: string,
193200 form: FormData | null,
194201 body: string | null,
195202 cookies: Record<string, string>,
@@ -202,6 +209,7 @@ function context(
202209 params,
203210 session,
204211 who,
212 backTo,
205213 form,
206214 body,
207215 cart: decodeCart(cookies[CART_COOKIE], secret),
src/shop.ts +118 −41
@@ -2,9 +2,11 @@
22 * The storefront's handlers: browse, cart, checkout, payment, order.
33 *
44 * The rules that matter live here. A cart cookie may say anything; prices and
5 * stock come from the database. An order is reached only with its access
6 * token, compared in constant time. Checkout and payment are rate limited.
7 * Nothing that takes money happens on a GET.
5 * stock come from the database. Browsing and the cart are open to anybody;
6 * buying needs an account, because an order belongs to one. An order opens for
7 * the account that placed it, or for whoever holds its access token, compared
8 * in constant time — and that decision is made in `openOrder` alone. Checkout
9 * and payment are rate limited. Nothing that takes money happens on a GET.
810 */
911
1012 import * as cart from "./cart.ts";
@@ -31,12 +33,13 @@ import {
3133 variantsOf,
3234 } from "./catalog.ts";
3335 import { field, handOff, intField, looksLikeEmail, redirect, text } from "./http.ts";
34 import { find as findPage } from "./pages/find.ts";
36 import { page as ordersPageView } from "./pages/orders.ts";
3537 import { render, type Ctx } from "./context.ts";
3638 import { page as cartPage } from "./pages/cart.ts";
3739 import { chosen } from "./gateway.ts";
3840 import { equals, hmac, sign, unsign } from "./sign.ts";
3941 import { forOrder } from "./mail.ts";
42 import { linkTo, opensByAccount } from "./orderlink.ts";
4043 import { dueCents, parseQuantity } from "./money.ts";
4144 import { escape } from "./html.ts";
4245 import type { Order } from "./db.ts";
@@ -58,11 +61,16 @@ const saveCodes = (ctx: Ctx, applied: codes.Applied): void => {
5861 ctx.codes = applied;
5962 };
6063
61 /** The order numbers this browser has placed, so `/orders` can list them. */
64 /** The guest orders this browser has found, so `/orders` can list them. */
6265 const myNumbers = (ctx: Ctx): string[] => mine.decode(ctx.cookies[mine.COOKIE], ctx.secret);
6366
64 const rememberOrder = (ctx: Ctx, number: string): void => {
65 ctx.setCookie(jar(ctx, mine.COOKIE, mine.encode(mine.remember(myNumbers(ctx), number), ctx.secret)));
67 /**
68 * An order an account owns is listed from the account, so only a guest order
69 * — one with nothing but its token — is worth a cookie.
70 */
71 const rememberOrder = (ctx: Ctx, order: Order): void => {
72 if (order.buyer_id !== "") return;
73 ctx.setCookie(jar(ctx, mine.COOKIE, mine.encode(mine.remember(myNumbers(ctx), order.number), ctx.secret)));
6674 };
6775
6876 /** A shared browser should be able to stop being this shopper's. */
@@ -157,7 +165,7 @@ const CART_MESSAGES: Record<string, string> = {
157165 export function cartView(ctx: Ctx, error?: string): Response {
158166 const quote = cart.quote(ctx.cart, { codes: ctx.codes });
159167 const key = (ctx.url.searchParams.get("m") ?? "").slice(0, 20);
160 return render(ctx, { title: "Cart" }, cartPage(quote, ctx.session, error ?? CART_MESSAGES[key]));
168 return render(ctx, { title: "Cart" }, cartPage(quote, ctx.session, ctx.who, error ?? CART_MESSAGES[key]));
161169 }
162170
163171 /**
@@ -239,19 +247,43 @@ const idempotencyOk = (ctx: Ctx, given: string): string | undefined => {
239247 return opened !== undefined && opened.startsWith(`idem:${ctx.session.sid}:`) ? opened : undefined;
240248 };
241249
250 /**
251 * The address form, or — for a shopper the forge has not signed in — the same
252 * order summary with one way forward: sign in. Nothing about the cart is
253 * touched either way, so the cart is still there when they come back.
254 *
255 * The forge knows an account's name, not its email address, and an account
256 * name is not one: the email box is prefilled only when what the forge gave us
257 * is itself an email, and the account's name is written on the order beside
258 * it, never into the email column.
259 */
242260 export function checkoutForm(ctx: Ctx, error?: string, values: Record<string, string> = {}): Response {
243261 const quote = cart.quote(ctx.cart, { codes: ctx.codes });
244262 if (quote.lines.length === 0) return redirect("/cart");
263 const prefill =
264 ctx.who.role === "anon" || !looksLikeEmail(ctx.who.name) ? {} : { email: ctx.who.name };
245265 return render(
246266 ctx,
247267 { title: "Checkout" },
248 pages.checkout(quote, ctx.session, chosen(), idempotencyFor(ctx), { country: tax.HOME_COUNTRY, ...values }, error),
268 pages.checkout({
269 quote,
270 session: ctx.session,
271 who: ctx.who,
272 gateway: chosen(),
273 idempotencyKey: idempotencyFor(ctx),
274 values: { country: tax.HOME_COUNTRY, ...prefill, ...values },
275 error,
276 }),
249277 );
250278 }
251279
252280 export function placeOrder(ctx: Ctx): Response {
253281 if (!limit.take(limit.CHECKOUT, ctx.peer))
254282 return tooMany(ctx, limit.retryAfter(limit.CHECKOUT, ctx.peer), "checkout");
283 // An order belongs to an account, so there is nothing to place without one:
284 // no row, no stock taken, no code redeemed. Back to the page that says so.
285 if (ctx.who.role === "anon") return redirect("/checkout");
286 const buyer = { id: ctx.who.id, name: ctx.who.name };
255287 const values = {
256288 email: field(ctx.form, "email", 200),
257289 name: field(ctx.form, "name", 120),
@@ -276,6 +308,7 @@ export function placeOrder(ctx: Ctx): Response {
276308 const shown = cart.quote(ctx.cart, { codes: ctx.codes });
277309 const result = orders.place({
278310 address: values,
311 buyer,
279312 cart: ctx.cart,
280313 codes: ctx.codes,
281314 shown,
@@ -285,27 +318,40 @@ export function placeOrder(ctx: Ctx): Response {
285318 if (!result.ok) return checkoutForm(ctx, result.error, values);
286319 saveCart(ctx, []);
287320 saveCodes(ctx, codes.NONE);
288 rememberOrder(ctx, result.order.number);
321 rememberOrder(ctx, result.order);
289322 const where = dueCents(result.order) === 0 ? "orders" : "pay";
290323 return redirect(`/${where}/${result.order.number}?token=${encodeURIComponent(result.order.access_token)}`);
291324 }
292325
293326 // --- payment ----------------------------------------------------------------
294327
295 /** An order is only ever reached with its own token, compared in constant time. */
328 /**
329 * Who may open an order, decided here and nowhere else. Three ways in, in
330 * order of how much they prove: an admin, the account the order belongs to,
331 * and whoever holds the order's own access token — compared in constant time,
332 * because a token is a secret. A guest order from before accounts has no
333 * buyer, so the token is all it has; a signed-in stranger is a stranger.
334 *
335 * A token that opens a guest order is this browser finding it, exactly as the
336 * form at `/orders` is, so the number is remembered the same way: signing in
337 * from here drops the token from `to=` and lands on `/orders`, which has to
338 * be able to list the order that was open a moment ago.
339 */
296340 function openOrder(ctx: Ctx, tokenFrom: "query" | "form"): Order | null {
297341 const number = (ctx.params.number ?? "").slice(0, 20);
298342 const token =
299343 tokenFrom === "query" ? (ctx.url.searchParams.get("token") ?? "").slice(0, 64) : field(ctx.form, "token", 64);
300344 const order = orders.byNumber(number);
301345 if (!order) return null;
302 if (ctx.who.role === "admin") return order;
303 return token.length > 0 && equals(token, order.access_token) ? order : null;
346 if (opensByAccount(ctx.who, order)) return order;
347 if (token.length === 0 || !equals(token, order.access_token)) return null;
348 rememberOrder(ctx, order);
349 return order;
304350 }
305351
306 /** Where an order lives, for whoever holds its token. */
307 const orderLink = (order: Order): string =>
308 `/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`;
352 /** Where an order lives for whoever is being sent there: src/orderlink.ts. */
353 const orderLink = (ctx: Ctx, order: Order, page: "orders" | "pay" = "orders"): string =>
354 linkTo(ctx.who, order, page);
309355
310356 /**
311357 * An order a gateway has already paid something towards is not offered to a
@@ -317,7 +363,7 @@ const alreadyCharged = (order: Order): boolean => orders.shortfall(order) !== nu
317363 export function payPage(ctx: Ctx, error?: string): Response {
318364 const order = openOrder(ctx, "query");
319365 if (!order) return notFound(ctx);
320 if (order.status !== "pending_payment" || alreadyCharged(order)) return redirect(orderLink(order));
366 if (order.status !== "pending_payment" || alreadyCharged(order)) return redirect(orderLink(ctx, order));
321367 return render(
322368 ctx,
323369 { title: `Pay ${order.number}` },
@@ -330,8 +376,8 @@ export function payMock(ctx: Ctx): Response {
330376 if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment");
331377 const order = openOrder(ctx, "form");
332378 if (!order) return notFound(ctx);
333 if (order.status !== "pending_payment" || alreadyCharged(order)) return redirect(orderLink(order));
334 if (chosen().kind !== "mock") return redirect(`/pay/${order.number}?token=${encodeURIComponent(order.access_token)}`);
379 if (order.status !== "pending_payment" || alreadyCharged(order)) return redirect(orderLink(ctx, order));
380 if (chosen().kind !== "mock") return redirect(orderLink(ctx, order, "pay"));
335381 const decision = field(ctx.form, "decision", 10);
336382 if (decision !== "approve") {
337383 orders.note(order.id, "declined", "the mock gateway was asked to decline");
@@ -343,7 +389,7 @@ export function payMock(ctx: Ctx): Response {
343389 ref: `mock_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`,
344390 amount_cents: dueCents(order),
345391 });
346 return redirect(`/orders/${paid.number}?token=${encodeURIComponent(paid.access_token)}`);
392 return redirect(orderLink(ctx, paid));
347393 }
348394
349395 const payPageFor = (ctx: Ctx, order: Order, error: string): Response =>
@@ -358,8 +404,11 @@ export async function payStripe(ctx: Ctx): Promise<Response> {
358404 if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment");
359405 const order = openOrder(ctx, "form");
360406 if (!order) return notFound(ctx);
361 if (order.status !== "pending_payment" || alreadyCharged(order)) return redirect(orderLink(order));
407 if (order.status !== "pending_payment" || alreadyCharged(order)) return redirect(orderLink(ctx, order));
362408 if (chosen().kind !== "stripe") return payPageFor(ctx, order, "Stripe is not configured; use the mock gateway.");
409 // Stripe's two URLs carry the token whoever is paying: the browser leaves
410 // this origin and comes back, and a forge session that lapsed while it was
411 // away must not turn the way back into a 404.
363412 const back = `${publicOrigin()}/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`;
364413 const session = await gateway.startCheckout(order, orders.itemsOf(order.id), {
365414 success: back,
@@ -388,28 +437,49 @@ export async function orderPage(ctx: Ctx): Promise<Response> {
388437 }
389438
390439 /**
391 * "Find an order". The orders this browser remembers are listed with their
392 * own links; anything older is found by naming the order and the email it was
393 * placed with.
440 * `/orders`. An account's own orders, newest first, each opening without a
441 * token because the row says whose it is. A shopper the forge has not signed
442 * in gets the invitation to sign in and no list of anything.
443 *
444 * Under that, whoever is asking, the guest orders this browser has found —
445 * rows from before accounts, which have nothing but their token — and the form
446 * that finds one by its number and the email it was placed with.
394447 */
395 export function findForm(
448 export function ordersPage(
396449 ctx: Ctx,
397450 error?: string,
398451 values: { number?: string; email?: string } = {},
399452 ): Response {
453 const mine = ctx.who.role === "anon" ? [] : orders.forBuyer(ctx.who.id);
outdated

Anchored to this line because this forge's review API wants one, and it is also the fix.

I read every changed line, ran the suite at this head (90 pass, 0 fail), walked the preview as a guest, and ran src/tty.ts with FORGE_APP_USER_ROLE=user. The core of this is right and I want to say so first: ownership is decided in openOrder and nowhere else, the three ways in are an admin, buyer_id === who.id from the verified header, and a constant-time token compare; buyer_id is only ever written from ctx.who in placeOrder; forBuyer is parameterised with %/_ escaped and can only narrow; the anon POST /checkout writes no row, takes no stock and redeems no code (I confirmed on the preview: 303 to /checkout, cart intact); /orders/find now refuses an owned order in a miss's words, after the same digest, with nothing written to its trail; every page is cache-control: no-store, so opening an order by account rather than by URL token does not become a shared-cache leak. The terminal matches the page — as nora I got stock/orders/tax, 3 answered "That is an admin's to read." without drawing the tab, r was refused, / was offered — and desk.ordersFor is tested for nora/sam/admin/anon including % as a needle. The new behaviours all have tests that would fail without them.

Two things to do before this lands.

1. Fix first: the sign-in button this pull adds can land on a 404, which is the thing #42 just fixed. ordersPage passes path: returnTo(ctx.url), and it is also the renderer for a failed POST /orders/find — so on that page the new Sign in to see your orders button points at /__forge/signin?to=%2Forders%2Ffind, and GET /orders/find is a 404 (verified on the preview). A shopper who mistypes a guest lookup and then signs in lands on nothing. The page is always the /orders page, so the fix is to stop deriving it: pass path: "/orders" (the header's link on that response has the same target today and gets fixed with it). Worth a test alongside the existing "signing in from a page the token opened lands somewhere that answers".

2. It does not merge. Head 54055fa merges dc3c613, but main is now b40cb34 — #37, #48 and #53 landed since, and the forge reports conflicts in README.md and src/tty.ts. #53 rewrote desk.ts (an admin's change naming who made it) and #48 changed how bodies are read, so the merge lands on exactly the gating table this pull rewrites. Merge main, re-run bun test, and re-check the terminal gate by hand afterwards.

Smaller, take or leave:

  • desk.needsAdmin is now called only from units.test.ts; tty.ts uses knows/mayPress. Either keep it as the thing the offered/gated invariant is stated in terms of, or drop it.
  • payMock and the not-pending redirects still put ?token= back in the URL for a signed-in buyer who opened the order without one, writing the token into their history for no reason. order.buyer_id === "" ? "?token=…" : "" would keep it out.
  • src/who.ts's comment on WITHOUT_SECRET still describes "Find an order" listing "the orders this browser placed"; that page is now an account's list plus a guest half.
  • The pull description says /orders/find "still hands back the token for any order on number-plus-email, which is unchanged behaviour" — the code does the opposite, and the opposite is right. Please correct the description, since it is what the next reader trusts.

Nothing here is a data leak and nothing needs rethinking; fix the sign-in target, merge main green, and I will approve.

454 // The guest orders this browser found are shown to whoever is holding the
455 // cookie, signed in or not. A guest order is a guest's to find, and a cookie
456 // nobody can see is a cookie nobody can drop.
400457 const held = myNumbers(ctx)
401458 .map((number) => orders.byNumber(number))
402 .filter((order): order is Order => order !== null);
403 return render(ctx, { title: "Find an order" }, findPage(held, ctx.session, values, error));
459 .filter((order): order is Order => order !== null && order.buyer_id === "");
460 return render(
461 ctx,
462 { title: "Your orders" },
463 ordersPageView({ who: ctx.who, mine, guests: held, session: ctx.session, path: ctx.backTo, values, error }),
464 );
404465 }
405466
406467 /**
407 * The number and the email must both be right, and a wrong pair is refused in
408 * the same words whether or not the order exists: this form must not be a way
409 * to learn which order numbers are real. The email is compared as a keyed
410 * digest, so neither its length nor its first wrong letter times differently,
411 * and a number that matches nothing is digested against a fixed string so
412 * that both paths do the same work as well as say the same thing.
468 * This form is the guest door, and only the guest door. An order that belongs
469 * to an account has a door of its own — its buyer signs in and `/orders` lists
470 * it — so handing back its access token to whoever knows a number and an email
471 * address would be giving away an owned order to a stranger. Since accounts,
472 * the pair is no longer proof of anything; it was only ever the best a guest
473 * order had.
474 *
475 * So: the number and the email must both be right, the order must be nobody's,
476 * and every refusal is the same words whether the order does not exist, does
477 * not match, or is not this form's to hand over. This must not be a way to
478 * learn which order numbers are real, nor which of them belong to an account.
479 * The email is compared as a keyed digest, so neither its length nor its first
480 * wrong letter times differently, and a number that matches nothing is
481 * digested against a fixed string so that every path does the same work as
482 * well as says the same thing.
413483 */
414484 /**
415485 * Stands in for the email of an order that is not there. The email on the form
@@ -418,24 +488,31 @@ export function findForm(
418488 */
419489 const NO_SUCH_EMAIL = "no-such-order";
420490
491 const NOT_FOUND =
492 "No guest order here matches that number and email address. An order bought with an account is not " +
493 "found this way: sign in, and it is listed in your orders.";
494
421495 export function findOrder(ctx: Ctx): Response {
422496 if (!limit.take(limit.LOOKUP, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.LOOKUP, ctx.peer), "lookup");
423497 const number = field(ctx.form, "number", 20).toUpperCase();
424498 const email = field(ctx.form, "email", 200);
425499 const values = { number, email };
426500 if (!mine.looksLikeNumber(number) || !looksLikeEmail(email))
427 return findForm(ctx, "An order number looks like BZ-XXXXXXXX, with the email it was placed with.", values);
501 return ordersPage(ctx, "An order number looks like BZ-XXXXXXXX, with the email it was placed with.", values);
428502
429 const order = orders.byNumber(number);
503 const found = orders.byNumber(number);
430504 const digest = (value: string): string => hmac(ctx.secret, `order-email:${value.trim().toLowerCase()}`);
431 const matched = equals(digest(email), digest(order?.email ?? NO_SUCH_EMAIL));
432 if (!order || !matched)
433 return findForm(ctx, "No order here matches that number and email address.", values);
505 const matched = equals(digest(email), digest(found?.email ?? NO_SUCH_EMAIL));
506 // An owned order is refused after the same digest a miss does, and leaves no
507 // mark on the order's trail: a stranger learns nothing, not even that they
508 // guessed right.
509 const order = found !== null && found.buyer_id === "" ? found : null;
510 if (!order || !matched) return ordersPage(ctx, NOT_FOUND, values);
434511
435512 // The address, not the header: what goes in the trail has to be a fact.
436513 orders.note(order.id, "looked-up", `found from ${ctx.peer.address}`);
437 rememberOrder(ctx, order.number);
438 return redirect(`/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`);
514 rememberOrder(ctx, order);
515 return redirect(orderLink(ctx, order));
439516 }
440517
441518 export async function receiptPage(ctx: Ctx): Promise<Response> {
@@ -443,7 +520,7 @@ export async function receiptPage(ctx: Ctx): Promise<Response> {
443520 if (!order) return notFound(ctx);
444521 const email = forOrder(order.id);
445522 if (!email) return notFound(ctx);
446 return render(ctx, { title: `Receipt ${order.number}` }, pages.receipt(order, email));
523 return render(ctx, { title: `Receipt ${order.number}` }, pages.receipt(order, email, ctx.who));
447524 }
448525
449526 /**
src/stripe.test.ts +23 −13
@@ -259,6 +259,16 @@ class Shopper {
259259 }
260260 }
261261
262 /**
263 * A shopper with an account, because buying needs one: an order belongs to
264 * the account that placed it. Each call is a different person.
265 */
266 let accounts = 0;
267 const asCustomer = (): Shopper => {
268 accounts += 1;
269 return new Shopper({ id: `u_shopper_${accounts}`, name: `shopper${accounts}`, role: "user" });
270 };
271
262272 // --- what the database says --------------------------------------------------
263273
264274 const variantId = (sku: string): number =>
@@ -380,7 +390,7 @@ async function admin(): Promise<Shopper> {
380390 // --- the Checkout Session ----------------------------------------------------
381391
382392 test("a Checkout Session is built from the order's lines and asks for what is due", async () => {
383 const shopper = new Shopper();
393 const shopper = asCustomer();
384394 const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 2 }]);
385395 const order = orderNamed(placed.number);
386396 const at = seen.length;
@@ -460,7 +470,7 @@ async function issueGiftCard(amount: string, note: string): Promise<string> {
460470 }
461471
462472 test("a discount and a gift card come off what Stripe is asked for", async () => {
463 const shopper = new Shopper();
473 const shopper = asCustomer();
464474 const code = await issueGiftCard("10.00", "for the Stripe path");
465475 const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-1000", quantity: 1 }], ["welcome10", code]);
466476 const order = orderNamed(placed.number);
@@ -501,7 +511,7 @@ test("a discount and a gift card come off what Stripe is asked for", async () =>
501511 // --- coming back -------------------------------------------------------------
502512
503513 test("the shopper coming back settles the order once", async () => {
504 const shopper = new Shopper();
514 const shopper = asCustomer();
505515 const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 1 }]);
506516 const started = await pressPayWithCard(shopper, placed);
507517 const sessionId = sessionIdIn(started.headers.get("location")!);
@@ -540,7 +550,7 @@ test("the shopper coming back settles the order once", async () => {
540550 // --- the webhook -------------------------------------------------------------
541551
542552 test("a signed webhook pays an order that never came back", async () => {
543 const shopper = new Shopper();
553 const shopper = asCustomer();
544554 const placed = await placeOrder(shopper, [{ sku: "BRW-FILT-100", quantity: 2 }]);
545555 const started = await pressPayWithCard(shopper, placed);
546556 const sessionId = sessionIdIn(started.headers.get("location")!);
@@ -565,7 +575,7 @@ test("a signed webhook pays an order that never came back", async () => {
565575 });
566576
567577 test("a webhook after a return leaves exactly one charge", async () => {
568 const shopper = new Shopper();
578 const shopper = asCustomer();
569579 const placed = await placeOrder(shopper, [{ sku: "KIT-MUG-OAT", quantity: 1 }]);
570580 const started = await pressPayWithCard(shopper, placed);
571581 const sessionId = sessionIdIn(started.headers.get("location")!);
@@ -595,7 +605,7 @@ test("a webhook after a return leaves exactly one charge", async () => {
595605 // --- a payment that does not cover the order ---------------------------------
596606
597607 test("a short return is written down and the order is not called paid", async () => {
598 const shopper = new Shopper();
608 const shopper = asCustomer();
599609 const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 2 }]);
600610 const started = await pressPayWithCard(shopper, placed);
601611 const sessionId = sessionIdIn(started.headers.get("location")!);
@@ -646,7 +656,7 @@ test("a short return is written down and the order is not called paid", async ()
646656 });
647657
648658 test("a short webhook leaves the order pending and the shortfall on the admin page", async () => {
649 const shopper = new Shopper();
659 const shopper = asCustomer();
650660 const placed = await placeOrder(shopper, [{ sku: "BRW-FILT-100", quantity: 1 }]);
651661 const started = await pressPayWithCard(shopper, placed);
652662 const sessionId = sessionIdIn(started.headers.get("location")!);
@@ -674,7 +684,7 @@ test("a short webhook leaves the order pending and the shortfall on the admin pa
674684 });
675685
676686 test("the exact amount still settles once, and a short event afterwards changes nothing", async () => {
677 const shopper = new Shopper();
687 const shopper = asCustomer();
678688 const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 2 }]);
679689 const started = await pressPayWithCard(shopper, placed);
680690 const sessionId = sessionIdIn(started.headers.get("location")!);
@@ -700,7 +710,7 @@ test("the exact amount still settles once, and a short event afterwards changes
700710 // --- refunds -----------------------------------------------------------------
701711
702712 test("an admin's refund goes to Stripe with the intent, the cents and a key", async () => {
703 const shopper = new Shopper();
713 const shopper = asCustomer();
704714 const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-1000", quantity: 1 }]);
705715 const started = await pressPayWithCard(shopper, placed);
706716 const sessionId = sessionIdIn(started.headers.get("location")!);
@@ -746,7 +756,7 @@ test("an admin's refund goes to Stripe with the intent, the cents and a key", as
746756 });
747757
748758 test("a refund Stripe refuses leaves the ledger exactly as it was", async () => {
749 const shopper = new Shopper();
759 const shopper = asCustomer();
750760 const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 1 }]);
751761 const started = await pressPayWithCard(shopper, placed);
752762 payAtStripe(sessionIdIn(started.headers.get("location")!));
@@ -777,7 +787,7 @@ test("a refund Stripe refuses leaves the ledger exactly as it was", async () =>
777787 // --- when Stripe is not there ------------------------------------------------
778788
779789 test("Stripe answering 500 leaves the order unpaid and says so in a sentence", async () => {
780 const shopper = new Shopper();
790 const shopper = asCustomer();
781791 const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 1 }]);
782792 failing = true;
783793 const response = await pressPayWithCard(shopper, placed);
@@ -792,7 +802,7 @@ test("Stripe answering 500 leaves the order unpaid and says so in a sentence", a
792802 });
793803
794804 test("Stripe unreachable leaves the order unpaid and says so in a sentence", async () => {
795 const shopper = new Shopper();
805 const shopper = asCustomer();
796806 const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 1 }]);
797807
798808 const at = seen.length;
@@ -813,7 +823,7 @@ test("Stripe unreachable leaves the order unpaid and says so in a sentence", asy
813823 });
814824
815825 test("a live key is refused and no request reaches Stripe", async () => {
816 const shopper = new Shopper();
826 const shopper = asCustomer();
817827 const placed = await placeOrder(shopper, [{ sku: "BRW-FILT-100", quantity: 1 }]);
818828
819829 // Same order, same database, an app whose key is a live one.
src/tty.ts +62 −34
@@ -8,17 +8,23 @@
88 * Reaching this screen is not the same as being allowed to use it: every key
99 * that changes something — and c, which reads a code out — is an admin's,
1010 * 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.
11 * business. The outbox, the codes and the cards are those: a receipt carries a
12 * gift card's code in full, and that code is money to whoever reads it.
13 *
14 * The orders tab is the one that is neither open nor closed, because the page
15 * is not either: an order belongs to the account that bought it, so whoever
16 * the forge named here sees the rows `/orders` would list for them and nothing
17 * else, an admin sees the shop's, and an anonymous keyboard sees none.
18 * `src/desk.ts` holds the one table all of these gates read, and `ordersFor`
19 * is the one place the rows are chosen.
1420 *
1521 * 1..7 switch between stock, orders, the outbox, discount codes, gift cards,
1622 * the tax table and the changes an admin made to any of it — everything
17 * /admin can see. j/k move, and each tab says
18 * at its foot what it can do: s moves stock with a reason, / finds an order by
19 * number, name or email, r refunds what is left of an order and puts it back
20 * on the shelf, n writes a code, i issues a card, e sets a tax rate, c looks a
21 * code up. q leaves. Plain ANSI, no dependencies.
23 * /admin can see. j/k move, and each tab says at its foot what it can do: s
24 * moves stock with a reason, / narrows the orders in front of you by number,
25 * name or email, r refunds what is left of an order and puts it back on the
26 * shelf, n writes a code, i issues a card, e sets a tax rate, c looks a code
27 * up. q leaves. Plain ANSI, no dependencies.
2228 */
2329
2430 import * as desk from "./desk.ts";
@@ -29,7 +35,7 @@ import { db } from "./db.ts";
2935 import { chosen } from "./gateway.ts";
3036 import { formatCents } from "./money.ts";
3137 import { history, addStock, setStock } from "./inventory.ts";
32 import { itemsOf, paymentsOf, recent, refund, refundableCents, refundedCents, shortfall } from "./orders.ts";
38 import { itemsOf, paymentsOf, refund, refundableCents, refundedCents, shortfall } from "./orders.ts";
3339 import { outbox } from "./mail.ts";
3440 import { atKeyboard } from "./who.ts";
3541 import { nameOf, recent as recentChanges, type Reading } from "./changes.ts";
@@ -38,7 +44,11 @@ import type { GiftCard, Order, TaxRate } from "./db.ts";
3844 const person = atKeyboard();
3945 const isAdmin = person.role === "admin";
4046 const readable = desk.visible(person).join(", ");
41 const onlyReads = ` — ${readable} read, nothing here changes anything`;
47 // What a non-admin gets, said on the first screen: the tabs that open at all,
48 // and that the orders among them are this account's own and nobody else's.
49 const onlyReads = ` — ${readable} read${
50 desk.canRead("orders", person) ? " (orders: your own)" : ""
51 }, nothing here changes anything`;
4252 const whoLine =
4353 person.role === "anon"
4454 ? `the forge did not name you (anonymous)${onlyReads}`
@@ -77,8 +87,9 @@ let message = "";
7787 // the same needle /admin/orders takes.
7888 let filter = "";
7989
80 // What the orders tab is showing: every order, or the ones `/` narrowed to.
81 const orderRows = (): Order[] => recent(100, filter);
90 // What the orders tab is showing: the orders this person may see — an admin's
91 // is the shop's, anybody else's is their own account's — narrowed by `/`.
92 const orderRows = (): Order[] => desk.ordersFor(person, filter);
8293
8394 const rowsFor = (): unknown[] =>
8495 tab === "stock"
@@ -101,9 +112,21 @@ const say = (done: desk.Done): string => (done.ok ? done.message : `${esc}33m${d
101112 /** The refusal the desk never gets to give, because a non-admin never reaches it. */
102113 const notYours = say({ ok: false, message: "That is an admin's to do." });
103114
115 /** The same for a tab: nothing on it is drawn, so nothing on it leaks. */
116 const notForYou = say({
117 ok: false,
118 message:
119 person.role === "anon"
120 ? "The forge did not name you, so nothing here is yours to read."
121 : "That is an admin's to read.",
122 });
123
124 /** True when this key is this person's to press on the tab they are on. */
125 const may = (key: string): boolean => desk.mayPress(tab, key, person);
126
104127 /**
105128 * The foot of the screen. A key this person may not press is not offered:
106 * `desk.offered` is the same list `desk.needsAdmin` gates on, h included.
129 * `desk.offered` is the same list `desk.mayPress` gates on, h included.
107130 */
108131 const hints = (): string =>
109132 [
@@ -117,7 +140,7 @@ const hints = (): string =>
117140 /** The tab bar. A tab this person may not read is named but marked closed. */
118141 const tabBar = (): string =>
119142 TABS.map((name, i) => {
120 const closed = !isAdmin && desk.needsAdminToRead(name);
143 const closed = !desk.canRead(name, person);
121144 const text = `${i + 1} ${name}${closed ? "·" : ""}`;
122145 return name === tab ? bold(text) : dim(text);
123146 }).join(" ");
@@ -127,6 +150,11 @@ const windowOf = (row: { starts_at: string | null; expires_at: string | null }):
127150
128151 function detail(order: Order): string[] {
129152 const lines = [""];
153 // The account it belongs to, beside the address the shopper typed. An order
154 // from before accounts belongs to nobody but whoever holds its token.
155 lines.push(
156 ` ${dim("account")} ${order.buyer_name === "" ? "guest" : order.buyer_name} ${dim("email")} ${order.email}`,
157 );
130158 for (const item of itemsOf(order.id))
131159 lines.push(
132160 ` ${item.product_title} — ${item.variant_name} x${item.quantity} ${formatCents(
@@ -293,32 +321,32 @@ process.stdin.on("data", (key: string) => {
293321 const count = rowsFor().length;
294322 if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1));
295323 if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0);
296 // A tab whose rows are the shop's own business is not opened at all for a
297 // non-admin: the screen stays where it was and says why, so nothing private
298 // is ever drawn.
324 // A tab this person may not read is not opened at all: the screen stays
325 // where it was and says why, so nothing private is ever drawn.
299326 const asTab = TABS[Number(key) - 1];
300 if (asTab && !isAdmin && desk.needsAdminToRead(asTab)) message = notYours;
327 if (asTab && !desk.canRead(asTab, person)) message = notForYou;
301328 else if (asTab) ((tab = asTab), (cursor = 0));
302 // Every key from here down either changes something or reads a code out, so
303 // every one of them is an admin's. A non-admin gets one refusal; the desk
304 // below is never reached.
305 if (!isAdmin && desk.needsAdmin(tab, key)) message = notYours;
306 if (key === "s" && tab === "stock" && isAdmin)
329 // Every key from here down either changes something, reads a code out, or
330 // narrows what is already on the screen. `desk.mayPress` says which of those
331 // this person may do; whoever may not gets one refusal and the desk below is
332 // never reached.
333 if (desk.knows(tab, key) && !may(key)) message = notYours;
334 if (key === "s" && tab === "stock" && may("s"))
307335 prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock };
308 if (key === "/" && tab === "orders" && isAdmin)
336 if (key === "/" && tab === "orders" && may("/"))
309337 prompt = { label: "Find an order (number, name or email):", text: filter, run: findOrders };
310 if (key === "r" && tab === "orders" && isAdmin) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest };
311 if (key === "c" && (tab === "codes" || tab === "cards") && isAdmin)
338 if (key === "r" && tab === "orders" && may("r")) prompt = { label: "Refund the rest — reason:", text: "", run: refundRest };
339 if (key === "c" && (tab === "codes" || tab === "cards") && may("c"))
312340 prompt = { label: "Check a code:", text: "", run: (value) => say(desk.look(value)) };
313 if (key === "n" && tab === "codes" && isAdmin)
341 if (key === "n" && tab === "codes" && may("n"))
314342 prompt = { label: `New code (${desk.NEW_DISCOUNT_SHAPE}):`, text: "", run: (value) => say(desk.newDiscount(value)) };
315 if (key === "t" && tab === "codes" && isAdmin) {
343 if (key === "t" && tab === "codes" && may("t")) {
316344 const code = discounts.withUse()[cursor];
317345 message = say(code ? desk.toggleDiscount(code.id) : { ok: false, message: "No code selected." });
318346 }
319 if (key === "i" && tab === "cards" && isAdmin)
347 if (key === "i" && tab === "cards" && may("i"))
320348 prompt = { label: "Issue a card (e.g. 25 a sorry-for-the-wait card):", text: "", run: (value) => say(desk.issueCard(value)) };
321 if (key === "v" && tab === "cards" && isAdmin) {
349 if (key === "v" && tab === "cards" && may("v")) {
322350 const card = giftcards.all()[cursor];
323351 prompt = {
324352 label: card && card.active !== 1 ? "Bring it back — reason:" : "Void it — reason:",
@@ -326,11 +354,11 @@ process.stdin.on("data", (key: string) => {
326354 run: (value) => say(card ? desk.toggleCard(card.id, value) : { ok: false, message: "No card selected." }),
327355 };
328356 }
329 if (key === "e" && tab === "tax" && isAdmin)
357 if (key === "e" && tab === "tax" && may("e"))
330358 prompt = { label: `Set a rate (${desk.RATE_SHAPE}):`, text: "", run: (value) => say(desk.setRate(value)) };
331 if (key === "d" && tab === "tax" && isAdmin)
359 if (key === "d" && tab === "tax" && may("d"))
332360 prompt = { label: "Rate anywhere the table does not name (e.g. 8.5):", text: "", run: (value) => say(desk.setDefaultRate(value)) };
333 if (key === "x" && tab === "tax" && isAdmin) {
361 if (key === "x" && tab === "tax" && may("x")) {
334362 const rate = tax.rates()[cursor];
335363 prompt = {
336364 label: rate ? `Remove ${rate.country} ${rate.region || "(whole country)"}? y/n:` : "Nothing selected — press enter:",
@@ -338,7 +366,7 @@ process.stdin.on("data", (key: string) => {
338366 run: (value) => say(rate ? desk.removeRate(rate.id, value) : { ok: false, message: "No rate selected." }),
339367 };
340368 }
341 if (key === "h" && tab === "stock" && isAdmin)
369 if (key === "h" && tab === "stock" && may("h"))
342370 message = history(8)
343371 .map((entry) => dim(`${entry.at} ${entry.sku} ${entry.delta > 0 ? "+" : ""}${entry.delta} ${entry.reason}`))
344372 .join("\r\n");
src/units.test.ts +94 −22
@@ -24,6 +24,7 @@ const giftcards = await import("./giftcards.ts");
2424 const live = await import("./live.ts");
2525 const tax = await import("./tax.ts");
2626 const orders = await import("./orders.ts");
27 const { getAnswers } = await import("./routes.ts");
2728 const dbModule = await import("./db.ts");
2829 const { db, forgetPasswordSettings, getSetting, setSetting } = dbModule;
2930
@@ -532,7 +533,7 @@ test("every key that does the desk's work needs an admin, and is offered to nobo
532533 const shopper = { role: "user", id: "7", name: "nora" } as const;
533534 const anon = { role: "anon" } as const;
534535
535 // The seven keys this pull added, plus the two that were already there.
536 // Every key that changes something, or reads a code out, is an admin's.
536537 expect(desk.needsAdmin("cards", "i")).toBe(true);
537538 expect(desk.needsAdmin("cards", "v")).toBe(true);
538539 expect(desk.needsAdmin("cards", "c")).toBe(true); // c reads a balance out
@@ -544,50 +545,108 @@ test("every key that does the desk's work needs an admin, and is offered to nobo
544545 expect(desk.needsAdmin("stock", "s")).toBe(true);
545546 expect(desk.needsAdmin("orders", "r")).toBe(true);
546547 expect(desk.needsAdmin("stock", "h")).toBe(true); // h reads the stock ledger out
548 // `/` narrows the rows already in front of whoever pressed it, and those are
549 // their own, so it is the one key a reader may press.
550 expect(desk.needsAdmin("orders", "/")).toBe(false);
551 expect(desk.mayPress("orders", "/", shopper)).toBe(true);
552 expect(desk.mayPress("orders", "/", anon)).toBe(false); // nothing to narrow
553 expect(desk.mayPress("orders", "r", shopper)).toBe(false);
547554
548555 // Moving and switching tabs are not the desk's, and a key is only the
549556 // desk's on the tab it belongs to.
550557 for (const tab of desk.TABS) for (const key of ["j", "k", "q", "1", "6"]) expect(desk.needsAdmin(tab, key)).toBe(false);
551558 expect(desk.needsAdmin("outbox", "i")).toBe(false);
552559 expect(desk.needsAdmin("stock", "n")).toBe(false);
560 expect(desk.knows("stock", "n")).toBe(false);
561 expect(desk.knows("orders", "r")).toBe(true);
553562
554563 for (const tab of desk.TABS) {
555 // The foot of the screen and the gate read the same table: everything
556 // offered needs an admin, and everything that needs one is offered.
564 // The foot of the screen and the gate read the same table: a key is
565 // offered to exactly the person who may press it.
557566 const keys = ["/", ..."abcdefghilmnoprstuvwxyz".split("")];
558 const gated = keys.filter((key) => desk.needsAdmin(tab, key));
559 expect(desk.offered(tab, admin).map((label) => label[0]).sort()).toEqual(gated.sort());
560 // Whoever the forge did not name an admin is offered nothing at all.
561 expect(desk.offered(tab, shopper)).toEqual([]);
567 for (const who of [admin, shopper, anon]) {
568 const pressable = keys.filter((key) => desk.mayPress(tab, key, who));
569 expect(desk.offered(tab, who).map((label) => label[0]).sort()).toEqual(pressable.sort());
570 }
571 // Whoever the forge did not name an admin is offered no work at all.
572 expect(desk.offered(tab, shopper).filter((label) => desk.needsAdmin(tab, label[0]!))).toEqual([]);
562573 expect(desk.offered(tab, anon)).toEqual([]);
563574 }
564575 expect(desk.offered("cards", admin)).toContain("i issue a card");
576 expect(desk.offered("orders", shopper)).toEqual(["/ find"]);
565577 });
566578
567 test("the tabs that print a code, a receipt or a person are an admin's to read", () => {
579 test("the tabs that print a code or a receipt are an admin's to read, and orders are the buyer's", () => {
568580 const admin = { role: "admin", id: "1", name: "root" } as const;
569581 const shopper = { role: "user", id: "7", name: "nora" } as const;
570582 const anon = { role: "anon" } as const;
571583
572 // A receipt in the outbox carries a gift card's code in full, an order
573 // carries a name and an address, the card list is every balance in the shop
574 // and the code list is every discount that exists — all of it the shop's.
575 expect(desk.needsAdminToRead("orders")).toBe(true);
576 expect(desk.needsAdminToRead("outbox")).toBe(true);
577 expect(desk.needsAdminToRead("cards")).toBe(true);
578 expect(desk.needsAdminToRead("codes")).toBe(true);
584 // A receipt in the outbox carries a gift card's code in full, the card list
585 // is every balance in the shop and the code list is every discount that
586 // exists — all of it the shop's, none of it any one shopper's.
587 for (const tab of ["outbox", "cards", "codes"] as const) {
588 expect(desk.canRead(tab, admin)).toBe(true);
589 expect(desk.canRead(tab, shopper)).toBe(false);
590 expect(desk.canRead(tab, anon)).toBe(false);
591 }
579592 // What is on the shelf and what it is taxed at is the shop's public face.
580 expect(desk.needsAdminToRead("stock")).toBe(false);
581 expect(desk.needsAdminToRead("tax")).toBe(false);
593 for (const tab of ["stock", "tax"] as const)
594 for (const who of [admin, shopper, anon]) expect(desk.canRead(tab, who)).toBe(true);
595 // Orders open for whoever the forge named, because what opens is their own.
596 expect(desk.canRead("orders", shopper)).toBe(true);
597 expect(desk.canRead("orders", anon)).toBe(false);
582598
583599 expect(desk.visible(admin)).toEqual([...desk.TABS]);
584 expect(desk.visible(shopper)).toEqual(["stock", "tax"]);
600 expect(desk.visible(shopper)).toEqual(["stock", "orders", "tax"]);
585601 expect(desk.visible(anon)).toEqual(["stock", "tax"]);
586602
587 // Nothing a non-admin can open offers a key they may not press, and the
588 // first tab the terminal opens on is one of them.
589 for (const tab of desk.visible(shopper)) expect(desk.offered(tab, shopper)).toEqual([]);
590 expect(desk.visible(shopper)[0]).toBe(desk.TABS[0]);
603 // The first tab the terminal opens on is one everybody can read.
604 expect(desk.visible(anon)[0]).toBe(desk.TABS[0]);
605 });
606
607 test("the terminal's orders tab is /orders: an account's own, an admin's the shop's, anon's none", () => {
608 const admin = { role: "admin", id: "u_admin", name: "root" } as const;
609 const nora = { role: "user", id: "u_nora", name: "nora" } as const;
610 const sam = { role: "user", id: "u_sam", name: "sam" } as const;
611 const anon = { role: "anon" } as const;
612
613 // 'token' for the access token, like every row this file writes by hand:
614 // what is under test is who an order is listed for, and nothing here opens
615 // one with its token.
616 const place = (buyer: { id: string; name: string } | null, email: string, name: string): string => {
617 const number = `BZ-${Math.random().toString(36).slice(2, 10).toUpperCase().replace(/[^A-Z0-9]/g, "X")}`;
618 db.query(
619 `INSERT INTO "order" (number, access_token, buyer_id, buyer_name, email, name, address1, city, postal, country,
620 subtotal_cents, shipping_cents, tax_cents, total_cents, gateway, idempotency_key)
621 VALUES (?1, 'token', ?2, ?3, ?4, ?5, '1 Road', 'Town', '12345', 'UNITED STATES', 1000, 0, 0, 1000, 'mock', ?1)`,
622 ).run(number, buyer?.id ?? "", buyer?.name ?? "", email, name);
623 return number;
624 };
625
626 const hers = place(nora, "[email protected]", "Nora Example");
627 const his = place(sam, "[email protected]", "Sam Example");
628 const guest = place(null, "[email protected]", "A Guest");
629
630 const numbers = (who: Parameters<typeof desk.ordersFor>[0], filter = ""): string[] =>
631 desk.ordersFor(who, filter).map((order) => order.number);
632
633 // Her own, and nothing of his or the shop's guest orders.
634 expect(numbers(nora)).toContain(hers);
635 expect(numbers(nora)).not.toContain(his);
636 expect(numbers(nora)).not.toContain(guest);
637 expect(numbers(sam)).toEqual([his]);
638 // An admin sees the shop's, guest rows and all; anon sees nothing at all.
639 expect(numbers(admin)).toEqual(expect.arrayContaining([hers, his, guest]));
640 expect(numbers(anon)).toEqual([]);
641
642 // `/` narrows what is already in front of you; it never reaches past it,
643 // whatever is typed — another person's email, or a wildcard.
644 expect(numbers(nora, "nora")).toEqual([hers]);
645 expect(numbers(nora, "[email protected]")).toEqual([]);
646 expect(numbers(nora, "%")).toEqual([]);
647 expect(numbers(nora, his)).toEqual([]);
648 expect(numbers(admin, "[email protected]")).toEqual([his]);
649 expect(numbers(anon, "nora")).toEqual([]);
591650 });
592651
593652 test("a rate limit counts an address the caller cannot choose", () => {
@@ -757,3 +816,16 @@ test("two databases from the factory know nothing of each other", () => {
757816 elsewhere.close(false);
758817 }
759818 });
819
820 test("the route table knows which paths a GET answers", () => {
821 // /orders/find is shaped like /orders/:number, which a GET does answer —
822 // for an order called "find", which is a 404. The pattern is what counts.
823 expect(getAnswers("/orders/find")).toBe(false);
824 expect(getAnswers("/pay/:number/mock")).toBe(false);
825 expect(getAnswers("/orders/forget")).toBe(false);
826 expect(getAnswers("/orders")).toBe(true);
827 expect(getAnswers("/orders/:number")).toBe(true);
828 // both methods on one path: the page is still its own way back
829 expect(getAnswers("/checkout")).toBe(true);
830 expect(getAnswers("/gift-cards")).toBe(true);
831 });
src/who.test.ts +16 −4
@@ -1,7 +1,7 @@
11 /** The identity check, on its own: what the forge signs, and what it does not. */
22
33 import { expect, test } from "bun:test";
4 import { atKeyboard, returnTo, signInHref, stamp, whoIs, WITHOUT_SECRET } from "./who.ts";
4 import { atKeyboard, returnTo, signInHref, SOMEWHERE_THAT_ANSWERS, stamp, whoIs } from "./who.ts";
55
66 const KEY = "a-key-only-the-forge-and-this-app-know";
77
@@ -105,13 +105,25 @@ test("a page the token opened comes back as one that opens without it", () => {
105105 const at = (href: string) => returnTo(new URL(href, "https://bazaar.example"));
106106 // /pay/1001 without its token is a 404, so the return is "Find an order",
107107 // which answers for anyone and links the orders this browser placed.
108 expect(at("/pay/1001?token=secret")).toBe(WITHOUT_SECRET);
109 expect(at("/orders/1001?token=secret")).toBe(WITHOUT_SECRET);
110 expect(at("/orders/1001/receipt?token=secret&m=x")).toBe(WITHOUT_SECRET);
108 expect(at("/pay/1001?token=secret")).toBe(SOMEWHERE_THAT_ANSWERS);
109 expect(at("/orders/1001?token=secret")).toBe(SOMEWHERE_THAT_ANSWERS);
110 expect(at("/orders/1001/receipt?token=secret&m=x")).toBe(SOMEWHERE_THAT_ANSWERS);
111111 // an empty token opened nothing: that page is still its own return
112112 expect(at("/orders/1001?token=")).toBe("/orders/1001");
113113 });
114114
115 test("a page no GET answers comes back as one that does", () => {
116 const at = (href: string, answersGet: boolean) => returnTo(new URL(href, "https://bazaar.example"), answersGet);
117 // POST-only: /orders/find renders "Your orders" when a lookup misses, and
118 // /pay/1001/mock renders the payment page when the mock declines. A GET of
119 // either is a 404 — the front door says so from the route table — so
120 // neither is a page to come back to.
121 expect(at("/orders/find", false)).toBe(SOMEWHERE_THAT_ANSWERS);
122 expect(at("/pay/1001/mock", false)).toBe(SOMEWHERE_THAT_ANSWERS);
123 // a page that does answer is still its own return, query and all
124 expect(at("/cart?m=applied", true)).toBe("/cart?m=applied");
125 });
126
115127 test("a path a browser would read as a host comes back as the root", () => {
116128 const at = (pathname: string) => {
117129 const url = new URL("https://bazaar.example/");
src/who.ts +14 −9
@@ -91,8 +91,12 @@ export const signInHref = (to: string): string => `/__forge/signin?to=${encodeUR
9191 *
9292 * A page the secret was what opened cannot come back as itself: `/pay/1001`
9393 * without its token is a 404, and a shopper part-way through paying who
94 * signed in would land on nothing. When a secret is dropped, `WITHOUT_SECRET`
95 * comes back instead — a page that opens for whoever follows it.
94 * signed in would land on nothing. Neither can a page that only a POST
95 * reaches: `/orders/find` renders "Your orders" when a lookup misses, but a
96 * GET of it is a 404, so coming back to where the browser is standing is the
97 * same dead end. Both land on `SOMEWHERE_THAT_ANSWERS` instead — hence
98 * `answersGet`, which the front door hands in from the route table: whether
99 * this request's own path is one a GET renders a page for.
96100 *
97101 * `//evil.com/x` is a path to us and an off-origin URL to a browser, so a
98102 * pathname that could be read as a host does not travel: the root comes back
@@ -102,18 +106,19 @@ export const signInHref = (to: string): string => `/__forge/signin?to=${encodeUR
102106 const SECRET_PARAMS = ["token"];
103107
104108 /**
105 * Where a page that only opened because of a secret sends a person instead.
106 * "Find an order" answers for anybody, and it lists the orders this browser
107 * placed with their own token links, so the order — or the payment that was
108 * half-made — is one click from where the return lands.
109 * Where a person lands when the page they are on cannot be come back to.
110 * `/orders` opens for anybody: an account's own orders once the forge has
111 * signed them in, and under them the guest orders this browser has found,
112 * each with its own token link — so the order, or the payment that was
113 * half-made, is one click from where the return lands.
109114 */
110 export const WITHOUT_SECRET = "/orders";
115 export const SOMEWHERE_THAT_ANSWERS = "/orders";
111116
112 export function returnTo(url: URL): string {
117 export function returnTo(url: URL, answersGet = true): string {
113118 if (!/^\/($|[^/\\])/.test(url.pathname)) return "/";
114119 const query = new URLSearchParams(url.search);
115120 const opened = SECRET_PARAMS.some((name) => query.getAll(name).some((value) => value !== ""));
116 if (opened) return WITHOUT_SECRET;
121 if (opened || !answersGet) return SOMEWHERE_THAT_ANSWERS;
117122 for (const name of SECRET_PARAMS) query.delete(name);
118123 const search = query.toString();
119124 const to = search === "" ? url.pathname : `${url.pathname}?${search}`;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs