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
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/10-an-order-belongs-to-the-account-that-bou → main 11 commits fork 2 threads · 2 unresolved
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 | ||
| 4 | 4 | variants and stock, a cart in a signed cookie, discount codes and gift cards, |
| 5 | 5 | tax by where the parcel goes, a gift card balance window, checkout with an |
| 6 | 6 | address, a payment step, receipts, and an `/admin` for products, inventory, |
| 7 | codes, cards, tax and orders with refunds. Every price is an integer number | |
| 8 | of cents, every form carries a CSRF token, and checkout, payment, typing a | |
| 9 | code, reading a gift card's balance and finding an order are each rate | |
| 10 | limited on their own. A category or a search longer than 24 things is | |
| 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 | |
| 11 | 13 | paged, so a listing is never a page without an end and never hides what is |
| 12 | 14 | past the end of it. |
| 13 | 15 | |
| @@ -106,26 +108,71 @@ the goods, plus gift cards being bought — and only then is a card's balance | ||
| 106 | 108 | taken off to leave what the gateway is asked for. `bun test` reconciles a |
| 107 | 109 | cart with all three against the order it becomes. |
| 108 | 110 | |
| 109 | ## Finding an order again | |
| 111 | ## An account buys; browsing does not need one | |
| 110 | 112 | |
| 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: | |
| 114 | 116 | |
| 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. | |
| 129 | 176 | |
| 130 | 177 | ### What a rate limit is keyed on |
| 131 | 178 | |
| @@ -236,8 +283,8 @@ session cookie is still there, but it only carries the CSRF token now. | ||
| 236 | 283 | |
| 237 | 284 | | Who | What | |
| 238 | 285 | | --- | --- | |
| 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. | | |
| 241 | 288 | | **admin** | Runs the shop: every path under `/admin`, and any order without its token. | |
| 242 | 289 | |
| 243 | 290 | 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 | ||
| 248 | 295 | order's `token` is stripped, because it opens that order to whoever holds it |
| 249 | 296 | and the sign-in path is handed to the forge. A page the token was what opened |
| 250 | 297 | (`/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 | |
| 254 | 302 | `rel="noreferrer"`: `/__forge/*` shares this origin, so the `Referer` would |
| 255 | 303 | otherwise hand over the very URL `to` was careful to clean. |
| 256 | 304 | |
| 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. | |
| 268 | 342 | |
| 269 | 343 | Rate limits count the address the socket came from, never the first entry of |
| 270 | 344 | `x-forwarded-for` — see [](#what-a-rate-limit-is-keyed-on) |
| @@ -357,11 +431,12 @@ saying so. | ||
| 357 | 431 | | `src/db.ts` | The schema, and `open(dir)`: a directory in, a database out | |
| 358 | 432 | | `src/fresh.ts` | A database of a test file's own, opened before the file imports anything that queries | |
| 359 | 433 | | `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 | | |
| 361 | 435 | | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is | |
| 362 | 436 | | `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 | | |
| 363 | 438 | | `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 | | |
| 365 | 440 | | `src/peer.ts` `src/ratelimit.ts` | Where a request came from, as far as it can be proved; the buckets keyed on it | |
| 366 | 441 | | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox | |
| 367 | 442 | | `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` | ||
| 414 | 489 | switch between stock, orders, the outbox, discount codes, gift cards, the tax |
| 415 | 490 | table and the changes an admin made to any of it — everything `/admin` |
| 416 | 491 | 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: | |
| 420 | 495 | |
| 421 | 496 | | Tab | Keys | |
| 422 | 497 | | --- | --- | |
| 423 | 498 | | 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 | | |
| 425 | 500 | | 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 | |
| 426 | 501 | | 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 | |
| 427 | 502 | | 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 | |
| 428 | 503 | | 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 | |
| 429 | 504 | |
| 430 | 505 | `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"; | ||
| 11 | 11 | import type { CartLine } from "./cart.ts"; |
| 12 | 12 | import type { Peer } from "./peer.ts"; |
| 13 | 13 | import type { Session } from "./session.ts"; |
| 14 | import { returnTo } from "./who.ts"; | |
| 15 | 14 | import type { Who } from "./who.ts"; |
| 16 | 15 | |
| 17 | 16 | export type Ctx = { |
| @@ -22,6 +21,13 @@ export type Ctx = { | ||
| 22 | 21 | session: Session; |
| 23 | 22 | /** Who the forge says is asking, verified in src/who.ts. */ |
| 24 | 23 | 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; | |
| 25 | 31 | /** Parsed for POST routes; null for GET and for routes that read the body themselves. */ |
| 26 | 32 | form: FormData | null; |
| 27 | 33 | /** |
| @@ -54,7 +60,7 @@ export function render( | ||
| 54 | 60 | layout({ |
| 55 | 61 | title: options.title, |
| 56 | 62 | who: ctx.who, |
| 57 | path: returnTo(ctx.url), | |
| 63 | path: ctx.backTo, | |
| 58 | 64 | cartCount: cartCount(ctx.cart), |
| 59 | 65 | categories: categories(), |
| 60 | 66 | current: options.current, |
src/db.ts +9
| @@ -271,6 +271,11 @@ function migrate(handle: Database): void { | ||
| 271 | 271 | addColumn(handle, "order", "tax_label", "TEXT NOT NULL DEFAULT ''"); |
| 272 | 272 | addColumn(handle, "variant", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0"); |
| 273 | 273 | 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)'); | |
| 274 | 279 | } |
| 275 | 280 | |
| 276 | 281 | /** |
| @@ -341,6 +346,10 @@ export type Order = { | ||
| 341 | 346 | number: string; |
| 342 | 347 | access_token: string; |
| 343 | 348 | 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; | |
| 344 | 353 | email: string; |
| 345 | 354 | name: string; |
| 346 | 355 | address1: string; |
src/desk.ts +84 −31
| @@ -7,21 +7,28 @@ | ||
| 7 | 7 | * be tested without a pty — a command is text in and a message out, and the |
| 8 | 8 | * work itself goes through the same modules the admin pages use. |
| 9 | 9 | * |
| 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 | |
| 11 | 11 | * /admin does — issuing a card prints its code once, and looking a code up |
| 12 | 12 | * reads out a balance — so the forge has to have named an admin at the |
| 13 | 13 | * keyboard. `DESK` is the one place that says so, for reading as much as for |
| 14 | 14 | * doing: the gate in `src/tty.ts` and the foot of its screen both read it, so |
| 15 | 15 | * a key cannot be offered without being gated or gated without being offered, |
| 16 | 16 | * 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. | |
| 17 | 22 | */ |
| 18 | 23 | |
| 19 | 24 | import * as changes from "./changes.ts"; |
| 20 | 25 | import * as discounts from "./discounts.ts"; |
| 21 | 26 | import * as giftcards from "./giftcards.ts"; |
| 27 | import * as orders from "./orders.ts"; | |
| 22 | 28 | import * as tax from "./tax.ts"; |
| 23 | 29 | import { db } from "./db.ts"; |
| 24 | 30 | import { formatCents, parseCents } from "./money.ts"; |
| 31 | import type { Order } from "./db.ts"; | |
| 25 | 32 | import type { Who } from "./who.ts"; |
| 26 | 33 | |
| 27 | 34 | export type Done = { ok: boolean; message: string }; |
| @@ -39,50 +46,67 @@ export type Tab = (typeof TABS)[number]; | ||
| 39 | 46 | |
| 40 | 47 | /** |
| 41 | 48 | * 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. | |
| 43 | 50 | * |
| 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. | |
| 50 | 62 | * |
| 51 | 63 | * Stock and the tax table are the shop's public face — what is on the shelf |
| 52 | 64 | * 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. | |
| 53 | 69 | */ |
| 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", | |
| 58 | 75 | 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" }, | |
| 61 | 85 | ], |
| 62 | 86 | }, |
| 63 | 87 | outbox: { read: "admin", keys: [] }, |
| 64 | 88 | codes: { |
| 65 | 89 | read: "admin", |
| 66 | 90 | 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" }, | |
| 70 | 94 | ], |
| 71 | 95 | }, |
| 72 | 96 | cards: { |
| 73 | 97 | read: "admin", |
| 74 | 98 | 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" }, | |
| 78 | 102 | ], |
| 79 | 103 | }, |
| 80 | 104 | tax: { |
| 81 | 105 | read: "anyone", |
| 82 | 106 | 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" }, | |
| 86 | 110 | ], |
| 87 | 111 | }, |
| 88 | 112 | // 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: | ||
| 90 | 114 | changes: { read: "admin", keys: [] }, |
| 91 | 115 | }; |
| 92 | 116 | |
| 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"); | |
| 95 | 125 | |
| 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"); | |
| 98 | 129 | |
| 99 | 130 | /** 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)); | |
| 102 | 132 | |
| 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. */ | |
| 104 | 143 | 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 | : []; | |
| 106 | 159 | |
| 107 | 160 | const no = (message: string): Done => ({ ok: false, message }); |
| 108 | 161 | const yes = (message: string): Done => ({ ok: true, message }); |
src/html.ts +1 −1
| @@ -99,7 +99,7 @@ export function layout(chrome: Chrome): string { | ||
| 99 | 99 | <p><span class="env" style="background: ${env === "production" ? "#16653d" : "#1d4ed8"}">${escape(where)}</span> |
| 100 | 100 | ${commit ? `<span class="muted"> @ ${escape(commit)}</span>` : ""} |
| 101 | 101 | — 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> | |
| 103 | 103 | <p>${ |
| 104 | 104 | ssh |
| 105 | 105 | ? `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 | ||
| 68 | 68 | ? "a gift card, in full" |
| 69 | 69 | : "the mock gateway" |
| 70 | 70 | }.`, |
| 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.`, | |
| 72 | 74 | "This is a demonstration store: no goods ship and no money moves.", |
| 73 | 75 | ].join("\n"); |
| 74 | 76 | } |
src/mine.ts +6 −2
| @@ -1,11 +1,15 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 3 | 7 | * |
| 4 | 8 | * Numbers and nothing else: the access token that actually opens an order is |
| 5 | 9 | * read from the database on every render, so editing this cookie can only |
| 6 | 10 | * change which order number is being claimed — and a number alone opens |
| 7 | 11 | * 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 | |
| 9 | 13 | * `/orders`, which asks for the email as well. |
| 10 | 14 | */ |
| 11 | 15 |
src/orderlink.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** | |
| 2 | * Where an order lives, for the person being handed the link. | |
| 3 | * | |
| 4 | * An order opens two ways: for the account it belongs to — and for an admin — | |
| 5 | * because the row says so, or for whoever holds its access token. When the row | |
| 6 | * is enough, the token has no business in the URL: it would be a secret | |
| 7 | * written into that person's history, their bookmarks and the `Referer` of | |
| 8 | * every link they follow next, for a page that did not need it. When the row | |
| 9 | * is not enough, the token is the only thing that opens the page, so the link | |
| 10 | * carries it. | |
| 11 | * | |
| 12 | * This is not a second door. `openOrder` in `src/shop.ts` is still the only | |
| 13 | * place that decides who may open an order; this asks the same question about | |
| 14 | * a link rather than about a request, and `openOrder` reads `opensByAccount` | |
| 15 | * from here so the two cannot drift apart. | |
| 16 | */ | |
| 17 | ||
| 18 | import type { Order } from "./db.ts"; | |
| 19 | import type { Who } from "./who.ts"; | |
| 20 | ||
| 21 | /** True when the row itself opens this order for this person. */ | |
| 22 | export const opensByAccount = (who: Who, order: Order): boolean => | |
| 23 | who.role === "admin" || (who.role === "user" && order.buyer_id !== "" && order.buyer_id === who.id); | |
| 24 | ||
| 25 | /** What a link to this order carries in its query for this person: nothing, or the token. */ | |
| 26 | export const tokenQuery = (who: Who, order: Order): string => | |
| 27 | opensByAccount(who, order) ? "" : `?token=${encodeURIComponent(order.access_token)}`; | |
| 28 | ||
| 29 | /** The order's page, or its payment page, as this person should be sent to it. */ | |
| 30 | export const linkTo = (who: Who, order: Order, page: "orders" | "pay" = "orders"): string => | |
| 31 | `/${page}/${order.number}${tokenQuery(who, order)}`; |
src/orders.ts +45 −5
| @@ -38,6 +38,9 @@ export type Address = { | ||
| 38 | 38 | country: string; |
| 39 | 39 | }; |
| 40 | 40 | |
| 41 | /** The account an order belongs to: who the forge signed in, and nothing else. */ | |
| 42 | export type Buyer = { id: string; name: string }; | |
| 43 | ||
| 41 | 44 | export type PlaceResult = { ok: true; order: Order; reused: boolean } | { ok: false; error: string }; |
| 42 | 45 | |
| 43 | 46 | const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; |
| @@ -69,16 +72,41 @@ export const note = (orderId: number, kind: string, detail = ""): void => { | ||
| 69 | 72 | db.query("INSERT INTO order_event (order_id, kind, detail) VALUES (?1, ?2, ?3)").run(orderId, kind, detail); |
| 70 | 73 | }; |
| 71 | 74 | |
| 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 | ||
| 72 | 100 | export const recent = (limit = 50, query = ""): Order[] => { |
| 73 | 101 | const needle = query.trim(); |
| 74 | 102 | 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)}%`; | |
| 76 | 103 | return db |
| 77 | 104 | .query<Order, [string, number]>( |
| 78 | 105 | `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 '\\' | |
| 79 | 107 | ORDER BY id DESC LIMIT ?2`, |
| 80 | 108 | ) |
| 81 | .all(like, limit); | |
| 109 | .all(like(needle), limit); | |
| 82 | 110 | }; |
| 83 | 111 | |
| 84 | 112 | /** 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 | ||
| 123 | 151 | */ |
| 124 | 152 | export function place(input: { |
| 125 | 153 | address: Address; |
| 154 | /** The account buying. An order is nobody's only if it was placed before accounts. */ | |
| 155 | buyer: Buyer; | |
| 126 | 156 | cart: CartLine[]; |
| 127 | 157 | codes: Applied; |
| 128 | 158 | /** What the checkout page showed, to be told when it went stale. */ |
| @@ -163,10 +193,12 @@ export function place(input: { | ||
| 163 | 193 | try { |
| 164 | 194 | created = db |
| 165 | 195 | .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, | |
| 167 | 198 | subtotal_cents, discount_code, discount_cents, shipping_cents, tax_cents, tax_rate_bp, tax_label, |
| 168 | 199 | 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, | |
| 170 | 202 | $subtotal_cents, $discount_code, $discount_cents, $shipping_cents, $tax_cents, $tax_rate_bp, $tax_label, |
| 171 | 203 | $gift_card_cents, $total_cents, $gateway, $idempotency_key) |
| 172 | 204 | RETURNING *`, |
| @@ -174,6 +206,8 @@ export function place(input: { | ||
| 174 | 206 | .get({ |
| 175 | 207 | $number: orderNumber(), |
| 176 | 208 | $access_token: randomBytes(18).toString("base64url"), |
| 209 | $buyer_id: input.buyer.id.slice(0, 64), | |
| 210 | $buyer_name: input.buyer.name.slice(0, 120), | |
| 177 | 211 | $email: input.address.email, |
| 178 | 212 | $name: input.address.name, |
| 179 | 213 | $address1: input.address.address1, |
| @@ -233,7 +267,13 @@ export function place(input: { | ||
| 233 | 267 | if (!takeStock(item.variant_id, item.quantity, `sold on ${created.number}`, created.id)) |
| 234 | 268 | throw new Error(`OUT_OF_STOCK:${item.product_title} (${item.variant_name})`); |
| 235 | 269 | } |
| 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 | ); | |
| 237 | 277 | return created; |
| 238 | 278 | })(); |
| 239 | 279 | } catch (error) { |
src/pages/admin.ts +14 −3
| @@ -100,6 +100,14 @@ ${live( | ||
| 100 | 100 | ${live("latest-orders", ORDER_EVENTS, orderRows(latest))} |
| 101 | 101 | `; |
| 102 | 102 | |
| 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 | ||
| 103 | 111 | export function orderRows(orders: Order[]): string { |
| 104 | 112 | if (orders.length === 0) return '<p class="muted">No orders yet.</p>'; |
| 105 | 113 | // Fixed columns: a row arriving at the top cannot move the ones below it |
| @@ -112,7 +120,9 @@ export function orderRows(orders: Order[]): string { | ||
| 112 | 120 | (order) => `<tr> |
| 113 | 121 | <td><a href="/admin/orders/${escape(order.number)}">${escape(order.number)}</a></td> |
| 114 | 122 | <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> | |
| 116 | 126 | <td>${statusPill(order.status)}</td> |
| 117 | 127 | <td class="num price">${escape(formatCents(order.total_cents))}</td> |
| 118 | 128 | </tr>`, |
| @@ -124,7 +134,8 @@ export const orders = (list: Order[], query: string): string => ` | ||
| 124 | 134 | ${nav("orders")} |
| 125 | 135 | <h1>Orders</h1> |
| 126 | 136 | <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"> | |
| 128 | 139 | <button class="btn ghost">Search</button> |
| 129 | 140 | </form> |
| 130 | 141 | ${live("order-list", ORDER_EVENTS, orderRows(list))} |
| @@ -145,7 +156,7 @@ export function order( | ||
| 145 | 156 | return ` |
| 146 | 157 | ${nav("orders")} |
| 147 | 158 | <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)}${ | |
| 149 | 160 | found.payment_ref ? ` · ${escape(found.payment_ref)}` : "" |
| 150 | 161 | }</p> |
| 151 | 162 | ${message ? `<p class="notice${message.bad ? " bad" : ""}">${escape(message.text)}</p>` : ""} |
src/pages/cart.ts +15 −1
| @@ -5,8 +5,10 @@ | ||
| 5 | 5 | |
| 6 | 6 | import { csrfField, escape } from "../html.ts"; |
| 7 | 7 | import { formatCents } from "../money.ts"; |
| 8 | import { FORGE_LINK_ATTRS, signInHref } from "../who.ts"; | |
| 8 | 9 | import type { Quote } from "../cart.ts"; |
| 9 | 10 | import type { Session } from "../session.ts"; |
| 11 | import type { Who } from "../who.ts"; | |
| 10 | 12 | |
| 11 | 13 | const row = (label: string, value: string, className = ""): string => |
| 12 | 14 | `<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 | ||
| 65 | 67 | </div>`; |
| 66 | 68 | } |
| 67 | 69 | |
| 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 { | |
| 69 | 76 | if (quote.lines.length === 0) |
| 70 | 77 | return `<h1>Your cart</h1><p class="lede">Nothing in it yet.</p><p><a class="btn" href="/">Look around</a></p>`; |
| 71 | 78 | const rows = quote.lines |
| @@ -106,6 +113,13 @@ export function page(quote: Quote, session: Session, message?: string): string { | ||
| 106 | 113 | return ` |
| 107 | 114 | <h1>Your cart</h1> |
| 108 | 115 | ${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 | } | |
| 109 | 123 | ${quote.problems.concat(quote.code_problems).map((problem) => `<p class="notice bad">${escape(problem)}</p>`).join("\n")} |
| 110 | 124 | <table> |
| 111 | 125 | <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"; | ||
| 9 | 9 | import { csrfField, escape } from "../html.ts"; |
| 10 | 10 | import { dueCents, formatCents } from "../money.ts"; |
| 11 | 11 | import { codeBox, totals } from "./cart.ts"; |
| 12 | import { tokenQuery } from "../orderlink.ts"; | |
| 13 | import { FORGE_LINK_ATTRS, signInHref } from "../who.ts"; | |
| 12 | 14 | import type { Quote } from "../cart.ts"; |
| 13 | 15 | import type { Order, OrderItem, Payment } from "../db.ts"; |
| 14 | 16 | import type { Shortfall } from "../orders.ts"; |
| @@ -28,14 +30,22 @@ const field = ( | ||
| 28 | 30 | ${options.autocomplete ? `autocomplete="${escape(options.autocomplete)}"` : ""}> |
| 29 | 31 | </label></div>`; |
| 30 | 32 | |
| 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; | |
| 39 | 49 | const summary = quote.lines |
| 40 | 50 | .map( |
| 41 | 51 | (line) => |
| @@ -43,15 +53,21 @@ export function checkout( | ||
| 43 | 53 | <td class="num">${line.quantity}</td><td class="num price">${escape(formatCents(line.line_total_cents))}</td></tr>`, |
| 44 | 54 | ) |
| 45 | 55 | .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"> | |
| 52 | 67 | ${csrfField(session)} |
| 53 | <input type="hidden" name="idempotency" value="${escape(idempotencyKey)}"> | |
| 68 | <input type="hidden" name="idempotency" value="${escape(input.idempotencyKey)}"> | |
| 54 | 69 | <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> | |
| 55 | 71 | <div class="fields"> |
| 56 | 72 | ${field("email", "Email", values.email ?? "", { wide: true, type: "email", autocomplete: "email" })} |
| 57 | 73 | ${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 | ||
| 68 | 84 | </div> |
| 69 | 85 | <p class="muted" style="font-size:.88rem; margin-top:1rem">Payment is taken next, by ${escape(gateway.label)}.</p> |
| 70 | 86 | <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} | |
| 72 | 94 | <div> |
| 73 | 95 | <h2 style="margin-top:0">Your order</h2> |
| 74 | 96 | <table><tbody>${summary}</tbody></table> |
| @@ -221,6 +243,9 @@ export function confirmation( | ||
| 221 | 243 | // A card's code is printed once, on the receipt. Say so here: a buyer who |
| 222 | 244 | // closes this tab without opening it has bought something they cannot spend. |
| 223 | 245 | 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); | |
| 224 | 249 | return ` |
| 225 | 250 | <h1>${paid ? "Thank you" : "Order " + escape(order.number)}</h1> |
| 226 | 251 | <p class="lede">Order ${escape(order.number)} · ${statusPill(order.status)} · placed ${escape(order.created_at)} UTC</p> |
| @@ -229,22 +254,20 @@ ${ | ||
| 229 | 254 | ? shortPayment(order, short) |
| 230 | 255 | : order.status === "pending_payment" |
| 231 | 256 | ? `<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>` | |
| 233 | 258 | : "" |
| 234 | 259 | } |
| 235 | 260 | ${ |
| 236 | 261 | paid && email |
| 237 | 262 | ? `<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> | |
| 239 | 264 | — this store has no mail server, so receipts are kept here instead of sent.</p>` |
| 240 | 265 | : "" |
| 241 | 266 | } |
| 242 | 267 | ${ |
| 243 | 268 | paid && cards > 0 |
| 244 | 269 | ? `<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, | |
| 248 | 271 | and <a href="/gift-cards">/gift-cards</a> says what is left on one.</p>` |
| 249 | 272 | : "" |
| 250 | 273 | } |
| @@ -252,14 +275,19 @@ ${itemsTable(items)} | ||
| 252 | 275 | ${orderTotals(order, refunded)} |
| 253 | 276 | <h2>Shipping to</h2> |
| 254 | 277 | <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 | } | |
| 257 | 285 | ${who.role === "admin" ? `<a href="/admin/orders/${escape(order.number)}">Open in admin</a>.` : ""}</p> |
| 258 | 286 | `; |
| 259 | 287 | } |
| 260 | 288 | |
| 261 | 289 | /** 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 => ` | |
| 263 | 291 | <div class="receipt"> |
| 264 | 292 | <div class="head"> |
| 265 | 293 | <div><strong>From</strong> [email protected]</div> |
| @@ -270,6 +298,6 @@ export const receipt = (order: Order, email: Email): string => ` | ||
| 270 | 298 | <pre>${escape(email.body)}</pre> |
| 271 | 299 | </div> |
| 272 | 300 | <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> | |
| 274 | 302 | </p> |
| 275 | 303 | `; |
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[] = [ | ||
| 41 | 41 | { method: "POST", pattern: "/pay/:number/stripe", handler: shop.payStripe }, |
| 42 | 42 | { method: "POST", pattern: "/webhooks/stripe", handler: shop.stripeWebhook, csrf: false, raw: true }, |
| 43 | 43 | |
| 44 | { method: "GET", pattern: "/orders", handler: (ctx) => shop.findForm(ctx) }, | |
| 44 | { method: "GET", pattern: "/orders", handler: (ctx) => shop.ordersPage(ctx) }, | |
| 45 | 45 | { method: "POST", pattern: "/orders/find", handler: shop.findOrder }, |
| 46 | 46 | { method: "POST", pattern: "/orders/forget", handler: shop.forgetOrders }, |
| 47 | 47 | { method: "GET", pattern: "/orders/:number", handler: shop.orderPage }, |
| @@ -79,6 +79,16 @@ export const routes: Route[] = [ | ||
| 79 | 79 | |
| 80 | 80 | export type Match = { route: Route; params: Record<string, string> }; |
| 81 | 81 | |
| 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 | ||
| 82 | 92 | /** A path segment a browser mangled is still a string, not a crash. */ |
| 83 | 93 | const decodeOrRaw = (segment: string): string => { |
| 84 | 94 | try { |
src/server.test.ts +321 −79
| @@ -35,6 +35,11 @@ class Shopper { | ||
| 35 | 35 | /** Who the forge says this browser is; undefined is a signed-out visitor. */ |
| 36 | 36 | constructor(private person?: Person) {} |
| 37 | 37 | |
| 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 | ||
| 38 | 43 | private forge(): Record<string, string> { |
| 39 | 44 | return identityHeaders(this.person); |
| 40 | 45 | } |
| @@ -97,6 +102,17 @@ class Shopper { | ||
| 97 | 102 | /** An admin, the way the forge signs one in. */ |
| 98 | 103 | const asAdmin = (): Shopper => new Shopper({ id: "u_1", name: "ada", role: "admin" }); |
| 99 | 104 | |
| 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 | ||
| 100 | 116 | const variantId = (sku: string): number => |
| 101 | 117 | db.query<{ id: number }, [string]>("SELECT id FROM variant WHERE sku = ?1").get(sku)!.id; |
| 102 | 118 | const stockOf = (sku: string): number => |
| @@ -344,7 +360,7 @@ test("a cart cookie from somewhere else is ignored", async () => { | ||
| 344 | 360 | test("checkout refuses an address it cannot use", async () => { |
| 345 | 361 | const orders = (): number => db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM "order"').get()!.n; |
| 346 | 362 | const before = orders(); |
| 347 | const shopper = new Shopper(); | |
| 363 | const shopper = asCustomer(); | |
| 348 | 364 | const product = await shopper.page("/p/house-espresso"); |
| 349 | 365 | await shopper.post( |
| 350 | 366 | "/cart/add", |
| @@ -371,7 +387,7 @@ test("checkout refuses an address it cannot use", async () => { | ||
| 371 | 387 | }); |
| 372 | 388 | |
| 373 | 389 | test("a purchase takes stock, records a charge and writes a receipt", async () => { |
| 374 | const shopper = new Shopper(); | |
| 390 | const shopper = asCustomer(); | |
| 375 | 391 | const before = stockOf("COF-GUJI-250"); |
| 376 | 392 | const { number, token } = await buy(shopper, "COF-GUJI-250", 2); |
| 377 | 393 | 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 () = | ||
| 403 | 419 | |
| 404 | 420 | test("a discount, a part-spent gift card and a state's rate become an order that adds up", async () => { |
| 405 | 421 | limit.forget(); |
| 406 | const shopper = new Shopper(); | |
| 422 | const shopper = asCustomer(); | |
| 407 | 423 | // A card someone has already spent $15 of, so $35 of its $50 is left. |
| 408 | 424 | const { card, code } = giftcards.issue({ cents: 5000, note: "bought earlier" }); |
| 409 | 425 | 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 () => | ||
| 487 | 503 | const { card, code } = giftcards.issue({ cents: 3000, note: "one card, two shoppers" }); |
| 488 | 504 | |
| 489 | 505 | // 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(); | |
| 492 | 508 | for (const shopper of [first, second]) { |
| 493 | 509 | await addToCart(shopper, "COF-HOUSE-250", 1); |
| 494 | 510 | 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 () => | ||
| 539 | 555 | expect(giftcards.byId(card.id)!.balance_cents).toBe(0); |
| 540 | 556 | }); |
| 541 | 557 | |
| 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(); | |
| 544 | 560 | 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(); | |
| 548 | 704 | }); |
| 549 | 705 | |
| 550 | 706 | test("a declined payment leaves the order unpaid and charges nothing", async () => { |
| 551 | const shopper = new Shopper(); | |
| 707 | const shopper = asCustomer(); | |
| 552 | 708 | const product = await shopper.page("/p/stoneware-mug"); |
| 553 | 709 | await shopper.post("/cart/add", { variant: String(variantId("KIT-MUG-OAT")), quantity: "1" }, Shopper.tokenIn(product)); |
| 554 | 710 | const checkout = await shopper.page("/checkout"); |
| @@ -581,7 +737,7 @@ test("a declined payment leaves the order unpaid and charges nothing", async () | ||
| 581 | 737 | }); |
| 582 | 738 | |
| 583 | 739 | test("the same checkout form submitted twice places one order", async () => { |
| 584 | const shopper = new Shopper(); | |
| 740 | const shopper = asCustomer(); | |
| 585 | 741 | const product = await shopper.page("/p/paper-filters"); |
| 586 | 742 | await shopper.post("/cart/add", { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, Shopper.tokenIn(product)); |
| 587 | 743 | const checkout = await shopper.page("/checkout"); |
| @@ -604,7 +760,7 @@ test("the same checkout form submitted twice places one order", async () => { | ||
| 604 | 760 | }); |
| 605 | 761 | |
| 606 | 762 | test("something sold out cannot be bought", async () => { |
| 607 | const shopper = new Shopper(); | |
| 763 | const shopper = asCustomer(); | |
| 608 | 764 | const product = await shopper.page("/p/stoneware-mug"); |
| 609 | 765 | expect(product).toContain("sold out"); // KIT-MUG-RUST is seeded at zero |
| 610 | 766 | const added = await shopper.post( |
| @@ -637,7 +793,7 @@ test("something sold out cannot be bought", async () => { | ||
| 637 | 793 | |
| 638 | 794 | test("checkout is rate limited", async () => { |
| 639 | 795 | limit.forget(); |
| 640 | const shopper = new Shopper(); | |
| 796 | const shopper = asCustomer(); | |
| 641 | 797 | const product = await shopper.page("/p/paper-filters"); |
| 642 | 798 | await shopper.post("/cart/add", { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, Shopper.tokenIn(product)); |
| 643 | 799 | 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 | ||
| 678 | 834 | |
| 679 | 835 | test("the checkout bucket is the whole shop's, and it refills", async () => { |
| 680 | 836 | 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(); | |
| 682 | 840 | const product = await first.page("/p/paper-filters"); |
| 683 | 841 | await first.post("/cart/add", { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, Shopper.tokenIn(product)); |
| 684 | 842 | 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 () => { | ||
| 692 | 850 | // keyed on that and nothing else, so an emptied till is emptied for the next |
| 693 | 851 | // shopper too. That is the trade the capacity is set for: thirty checkouts a |
| 694 | 852 | // minute is a shop's minute, which ordinary trade never spends. |
| 695 | const second = new Shopper(); | |
| 853 | const second = asCustomer(); | |
| 696 | 854 | const theirs = await second.page("/p/paper-filters"); |
| 697 | 855 | await second.post("/cart/add", { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, Shopper.tokenIn(theirs)); |
| 698 | 856 | const checkout = await second.page("/checkout"); |
| @@ -720,7 +878,7 @@ test("an unsigned webhook is refused", async () => { | ||
| 720 | 878 | |
| 721 | 879 | test("/admin opens for an admin the forge signed, and refunds and restocks", async () => { |
| 722 | 880 | limit.forget(); |
| 723 | const shopper = new Shopper(); | |
| 881 | const shopper = asCustomer(); | |
| 724 | 882 | const { number } = await buy(shopper, "COF-GUJI-1000", 1); |
| 725 | 883 | |
| 726 | 884 | const admin = asAdmin(); |
| @@ -777,34 +935,109 @@ test("no link to the forge carries a referrer, so the page's query stays here", | ||
| 777 | 935 | }); |
| 778 | 936 | |
| 779 | 937 | test("signing in from a page the token opened lands somewhere that answers", async () => { |
| 938 | limit.forget(); | |
| 780 | 939 | // Mid-purchase on /pay/:number?token=…: the token is deliberately kept out |
| 781 | 940 | // 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); | |
| 785 | 945 | const { number, token } = orderAt(placed.headers.get("location")!); |
| 786 | 946 | |
| 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); | |
| 790 | 955 | expect(page).toContain("Sign in"); |
| 791 | 956 | const to = decodeURIComponent(/\/__forge\/signin\?to=([^"]+)"/.exec(page)![1]!); |
| 792 | 957 | expect(to).not.toContain(token); |
| 793 | 958 | |
| 794 | 959 | // 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); | |
| 798 | 962 | 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); | |
| 802 | 964 | }; |
| 803 | 965 | |
| 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(); | |
| 808 | 1041 | }); |
| 809 | 1042 | |
| 810 | 1043 | 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 | ||
| 1042 | 1275 | expect(full).not.toContain("The last 20 movements"); |
| 1043 | 1276 | limit.forget(); |
| 1044 | 1277 | }); |
| 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 () => { | |
| 1046 | 1279 | limit.forget(); |
| 1047 | const shopper = new Shopper(); | |
| 1280 | const shopper = asCustomer(); | |
| 1048 | 1281 | 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); | |
| 1049 | 1288 | |
| 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. | |
| 1051 | 1292 | const stranger = new Shopper(); |
| 1052 | 1293 | expect((await stranger.get(`/orders/${number}`)).status).toBe(404); |
| 1053 | 1294 | 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"); | |
| 1061 | 1303 | |
| 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); | |
| 1068 | 1308 | limit.forget(); |
| 1069 | 1309 | }); |
| 1070 | 1310 | |
| 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 () => { | |
| 1072 | 1312 | limit.forget(); |
| 1073 | const shopper = new Shopper(); | |
| 1313 | const shopper = asCustomer(); | |
| 1074 | 1314 | const form = await shopper.page("/orders"); |
| 1075 | 1315 | const refusal = async (fields: Record<string, string>): Promise<string> => |
| 1076 | 1316 | (await shopper.post("/orders/find", fields, Shopper.tokenIn(form))).text(); |
| 1077 | 1317 | const invented = await refusal({ number: "BZ-ZZZZZZZZ", email: "[email protected]" }); |
| 1078 | 1318 | const { number } = await buy(shopper, "COF-HOUSE-250", 1); |
| 1079 | 1319 | 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); | |
| 1082 | 1328 | limit.forget(); |
| 1083 | 1329 | }); |
| 1084 | 1330 | |
| @@ -1120,14 +1366,27 @@ test("a spent limit cannot be refilled by changing x-forwarded-for", async () => | ||
| 1120 | 1366 | limit.forget(); |
| 1121 | 1367 | }); |
| 1122 | 1368 | |
| 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 () => { | |
| 1124 | 1370 | 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); | |
| 1127 | 1373 | const mine = await shopper.page("/orders"); |
| 1128 | 1374 | 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"); | |
| 1131 | 1390 | limit.forget(); |
| 1132 | 1391 | }); |
| 1133 | 1392 | |
| @@ -1138,23 +1397,6 @@ test("the order-lookup form has a class of its own, not the header search's", as | ||
| 1138 | 1397 | expect(page.match(/class="find"/g)?.length).toBe(1); |
| 1139 | 1398 | }); |
| 1140 | 1399 | |
| 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 | ||
| 1158 | 1400 | test("a variant that is not the named product's own is not added to the cart", async () => { |
| 1159 | 1401 | const shopper = new Shopper(); |
| 1160 | 1402 | 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" | ||
| 1212 | 1454 | tax.setDefaultRateBp(2000); |
| 1213 | 1455 | tax.upsertRate({ country: tax.HOME_COUNTRY, region: "", rate_bp: 1000, label: "US sales tax", active: true }); |
| 1214 | 1456 | try { |
| 1215 | const shopper = new Shopper(); | |
| 1457 | const shopper = asCustomer(); // the order at the end of it needs an account | |
| 1216 | 1458 | await addToCart(shopper, "COF-GUJI-250", 2); // $38.00 of goods |
| 1217 | 1459 | |
| 1218 | 1460 | const cart = await shopper.page("/cart"); |
| @@ -1417,13 +1659,13 @@ const watch = async (stop: AbortController): Promise<Response> => { | ||
| 1417 | 1659 | return fetch(`${base}/admin/events?since=${here.id}`, { headers: ADMIN_HEADERS(), signal: stop.signal }); |
| 1418 | 1660 | }; |
| 1419 | 1661 | |
| 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 () => { | |
| 1421 | 1663 | const stop = new AbortController(); |
| 1422 | 1664 | const stream = await watch(stop); |
| 1423 | 1665 | expect(stream.status).toBe(200); |
| 1424 | 1666 | expect(stream.headers.get("content-type")).toBe("text/event-stream"); |
| 1425 | 1667 | |
| 1426 | const shopper = new Shopper(); | |
| 1668 | const shopper = asCustomer(); | |
| 1427 | 1669 | await addToCart(shopper, "COF-GUJI-250", 1); |
| 1428 | 1670 | const placed = await submitCheckout(shopper, { email: "[email protected]" }); |
| 1429 | 1671 | expect(placed.status).toBe(303); |
| @@ -1451,7 +1693,7 @@ test("an admin watching the stream hears an order a stranger places", async () = | ||
| 1451 | 1693 | }); |
| 1452 | 1694 | |
| 1453 | 1695 | test("paying and refunding an order reach the stream, once each", async () => { |
| 1454 | const shopper = new Shopper(); | |
| 1696 | const shopper = asCustomer(); | |
| 1455 | 1697 | await addToCart(shopper, "COF-HOUSE-250", 1); |
| 1456 | 1698 | const placed = await submitCheckout(shopper, { email: "[email protected]" }); |
| 1457 | 1699 | const { number, token } = orderAt(placed.headers.get("location")!); |
src/server.ts +12 −4
| @@ -24,14 +24,14 @@ import { | ||
| 24 | 24 | useSecret, |
| 25 | 25 | } from "./session.ts"; |
| 26 | 26 | import { loadSecret } from "./sign.ts"; |
| 27 | import { match } from "./routes.ts"; | |
| 27 | import { getAnswers, match } from "./routes.ts"; | |
| 28 | 28 | import { notFound } from "./shop.ts"; |
| 29 | 29 | import { peerOf, trustedHops } from "./peer.ts"; |
| 30 | 30 | import { seed } from "./catalog.ts"; |
| 31 | 31 | import { seed as seedDiscounts } from "./discounts.ts"; |
| 32 | 32 | import { seed as seedTax } from "./tax.ts"; |
| 33 | 33 | import { HAND_OFF, readForm, readText, text } from "./http.ts"; |
| 34 | import { whoIs } from "./who.ts"; | |
| 34 | import { returnTo, whoIs } from "./who.ts"; | |
| 35 | 35 | import type { Who } from "./who.ts"; |
| 36 | 36 | import type { Ctx } from "./context.ts"; |
| 37 | 37 | |
| @@ -138,6 +138,12 @@ export const server = Bun.serve({ | ||
| 138 | 138 | if (method === "POST" && !sameOrigin(request)) return finish(text("forbidden", 403), []); |
| 139 | 139 | |
| 140 | 140 | 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)); | |
| 141 | 147 | const session = loadSession(cookies[SESSION_COOKIE]); |
| 142 | 148 | // Identity is the forge's to assert and ours to verify; the cookie above |
| 143 | 149 | // only carries the CSRF token. |
| @@ -146,7 +152,7 @@ export const server = Bun.serve({ | ||
| 146 | 152 | outgoing.push(cookie(SESSION_COOKIE, session.sid, { maxAge: 60 * 60 * 24 * 30, secure: publicOrigin.startsWith("https:") })); |
| 147 | 153 | |
| 148 | 154 | 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); | |
| 150 | 156 | return finish(notFound(ctx), outgoing); |
| 151 | 157 | } |
| 152 | 158 | |
| @@ -173,7 +179,7 @@ export const server = Bun.serve({ | ||
| 173 | 179 | return finish(text("that form was not from here — go back and try again", 403), outgoing); |
| 174 | 180 | } |
| 175 | 181 | |
| 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); | |
| 177 | 183 | try { |
| 178 | 184 | const response = await found.route.handler(ctx); |
| 179 | 185 | return finish(request.method === "HEAD" ? new Response(null, response) : response, outgoing); |
| @@ -190,6 +196,7 @@ function context( | ||
| 190 | 196 | params: Record<string, string>, |
| 191 | 197 | session: ReturnType<typeof loadSession>, |
| 192 | 198 | who: Who, |
| 199 | backTo: string, | |
| 193 | 200 | form: FormData | null, |
| 194 | 201 | body: string | null, |
| 195 | 202 | cookies: Record<string, string>, |
| @@ -202,6 +209,7 @@ function context( | ||
| 202 | 209 | params, |
| 203 | 210 | session, |
| 204 | 211 | who, |
| 212 | backTo, | |
| 205 | 213 | form, |
| 206 | 214 | body, |
| 207 | 215 | cart: decodeCart(cookies[CART_COOKIE], secret), |
src/shop.ts +118 −41
| @@ -2,9 +2,11 @@ | ||
| 2 | 2 | * The storefront's handlers: browse, cart, checkout, payment, order. |
| 3 | 3 | * |
| 4 | 4 | * 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. | |
| 8 | 10 | */ |
| 9 | 11 | |
| 10 | 12 | import * as cart from "./cart.ts"; |
| @@ -31,12 +33,13 @@ import { | ||
| 31 | 33 | variantsOf, |
| 32 | 34 | } from "./catalog.ts"; |
| 33 | 35 | 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"; | |
| 35 | 37 | import { render, type Ctx } from "./context.ts"; |
| 36 | 38 | import { page as cartPage } from "./pages/cart.ts"; |
| 37 | 39 | import { chosen } from "./gateway.ts"; |
| 38 | 40 | import { equals, hmac, sign, unsign } from "./sign.ts"; |
| 39 | 41 | import { forOrder } from "./mail.ts"; |
| 42 | import { linkTo, opensByAccount } from "./orderlink.ts"; | |
| 40 | 43 | import { dueCents, parseQuantity } from "./money.ts"; |
| 41 | 44 | import { escape } from "./html.ts"; |
| 42 | 45 | import type { Order } from "./db.ts"; |
| @@ -58,11 +61,16 @@ const saveCodes = (ctx: Ctx, applied: codes.Applied): void => { | ||
| 58 | 61 | ctx.codes = applied; |
| 59 | 62 | }; |
| 60 | 63 | |
| 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. */ | |
| 62 | 65 | const myNumbers = (ctx: Ctx): string[] => mine.decode(ctx.cookies[mine.COOKIE], ctx.secret); |
| 63 | 66 | |
| 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))); | |
| 66 | 74 | }; |
| 67 | 75 | |
| 68 | 76 | /** A shared browser should be able to stop being this shopper's. */ |
| @@ -157,7 +165,7 @@ const CART_MESSAGES: Record<string, string> = { | ||
| 157 | 165 | export function cartView(ctx: Ctx, error?: string): Response { |
| 158 | 166 | const quote = cart.quote(ctx.cart, { codes: ctx.codes }); |
| 159 | 167 | 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])); | |
| 161 | 169 | } |
| 162 | 170 | |
| 163 | 171 | /** |
| @@ -239,19 +247,43 @@ const idempotencyOk = (ctx: Ctx, given: string): string | undefined => { | ||
| 239 | 247 | return opened !== undefined && opened.startsWith(`idem:${ctx.session.sid}:`) ? opened : undefined; |
| 240 | 248 | }; |
| 241 | 249 | |
| 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 | */ | |
| 242 | 260 | export function checkoutForm(ctx: Ctx, error?: string, values: Record<string, string> = {}): Response { |
| 243 | 261 | const quote = cart.quote(ctx.cart, { codes: ctx.codes }); |
| 244 | 262 | if (quote.lines.length === 0) return redirect("/cart"); |
| 263 | const prefill = | |
| 264 | ctx.who.role === "anon" || !looksLikeEmail(ctx.who.name) ? {} : { email: ctx.who.name }; | |
| 245 | 265 | return render( |
| 246 | 266 | ctx, |
| 247 | 267 | { 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 | }), | |
| 249 | 277 | ); |
| 250 | 278 | } |
| 251 | 279 | |
| 252 | 280 | export function placeOrder(ctx: Ctx): Response { |
| 253 | 281 | if (!limit.take(limit.CHECKOUT, ctx.peer)) |
| 254 | 282 | 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 }; | |
| 255 | 287 | const values = { |
| 256 | 288 | email: field(ctx.form, "email", 200), |
| 257 | 289 | name: field(ctx.form, "name", 120), |
| @@ -276,6 +308,7 @@ export function placeOrder(ctx: Ctx): Response { | ||
| 276 | 308 | const shown = cart.quote(ctx.cart, { codes: ctx.codes }); |
| 277 | 309 | const result = orders.place({ |
| 278 | 310 | address: values, |
| 311 | buyer, | |
| 279 | 312 | cart: ctx.cart, |
| 280 | 313 | codes: ctx.codes, |
| 281 | 314 | shown, |
| @@ -285,27 +318,40 @@ export function placeOrder(ctx: Ctx): Response { | ||
| 285 | 318 | if (!result.ok) return checkoutForm(ctx, result.error, values); |
| 286 | 319 | saveCart(ctx, []); |
| 287 | 320 | saveCodes(ctx, codes.NONE); |
| 288 | rememberOrder(ctx, result.order.number); | |
| 321 | rememberOrder(ctx, result.order); | |
| 289 | 322 | const where = dueCents(result.order) === 0 ? "orders" : "pay"; |
| 290 | 323 | return redirect(`/${where}/${result.order.number}?token=${encodeURIComponent(result.order.access_token)}`); |
| 291 | 324 | } |
| 292 | 325 | |
| 293 | 326 | // --- payment ---------------------------------------------------------------- |
| 294 | 327 | |
| 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 | */ | |
| 296 | 340 | function openOrder(ctx: Ctx, tokenFrom: "query" | "form"): Order | null { |
| 297 | 341 | const number = (ctx.params.number ?? "").slice(0, 20); |
| 298 | 342 | const token = |
| 299 | 343 | tokenFrom === "query" ? (ctx.url.searchParams.get("token") ?? "").slice(0, 64) : field(ctx.form, "token", 64); |
| 300 | 344 | const order = orders.byNumber(number); |
| 301 | 345 | 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; | |
| 304 | 350 | } |
| 305 | 351 | |
| 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); | |
| 309 | 355 | |
| 310 | 356 | /** |
| 311 | 357 | * 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 | ||
| 317 | 363 | export function payPage(ctx: Ctx, error?: string): Response { |
| 318 | 364 | const order = openOrder(ctx, "query"); |
| 319 | 365 | 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)); | |
| 321 | 367 | return render( |
| 322 | 368 | ctx, |
| 323 | 369 | { title: `Pay ${order.number}` }, |
| @@ -330,8 +376,8 @@ export function payMock(ctx: Ctx): Response { | ||
| 330 | 376 | if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment"); |
| 331 | 377 | const order = openOrder(ctx, "form"); |
| 332 | 378 | 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")); | |
| 335 | 381 | const decision = field(ctx.form, "decision", 10); |
| 336 | 382 | if (decision !== "approve") { |
| 337 | 383 | orders.note(order.id, "declined", "the mock gateway was asked to decline"); |
| @@ -343,7 +389,7 @@ export function payMock(ctx: Ctx): Response { | ||
| 343 | 389 | ref: `mock_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`, |
| 344 | 390 | amount_cents: dueCents(order), |
| 345 | 391 | }); |
| 346 | return redirect(`/orders/${paid.number}?token=${encodeURIComponent(paid.access_token)}`); | |
| 392 | return redirect(orderLink(ctx, paid)); | |
| 347 | 393 | } |
| 348 | 394 | |
| 349 | 395 | const payPageFor = (ctx: Ctx, order: Order, error: string): Response => |
| @@ -358,8 +404,11 @@ export async function payStripe(ctx: Ctx): Promise<Response> { | ||
| 358 | 404 | if (!limit.take(limit.PAYMENT, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.PAYMENT, ctx.peer), "payment"); |
| 359 | 405 | const order = openOrder(ctx, "form"); |
| 360 | 406 | 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)); | |
| 362 | 408 | 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. | |
| 363 | 412 | const back = `${publicOrigin()}/orders/${order.number}?token=${encodeURIComponent(order.access_token)}`; |
| 364 | 413 | const session = await gateway.startCheckout(order, orders.itemsOf(order.id), { |
| 365 | 414 | success: back, |
| @@ -388,28 +437,49 @@ export async function orderPage(ctx: Ctx): Promise<Response> { | ||
| 388 | 437 | } |
| 389 | 438 | |
| 390 | 439 | /** |
| 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. | |
| 394 | 447 | */ |
| 395 | export function findForm( | |
| 448 | export function ordersPage( | |
| 396 | 449 | ctx: Ctx, |
| 397 | 450 | error?: string, |
| 398 | 451 | values: { number?: string; email?: string } = {}, |
| 399 | 452 | ): Response { |
| 453 | const mine = ctx.who.role === "anon" ? [] : orders.forBuyer(ctx.who.id); | |
| 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. | |
| 400 | 457 | const held = myNumbers(ctx) |
| 401 | 458 | .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 | ); | |
| 404 | 465 | } |
| 405 | 466 | |
| 406 | 467 | /** |
| 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. | |
| 413 | 483 | */ |
| 414 | 484 | /** |
| 415 | 485 | * Stands in for the email of an order that is not there. The email on the form |
| @@ -418,24 +488,31 @@ export function findForm( | ||
| 418 | 488 | */ |
| 419 | 489 | const NO_SUCH_EMAIL = "no-such-order"; |
| 420 | 490 | |
| 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 | ||
| 421 | 495 | export function findOrder(ctx: Ctx): Response { |
| 422 | 496 | if (!limit.take(limit.LOOKUP, ctx.peer)) return tooMany(ctx, limit.retryAfter(limit.LOOKUP, ctx.peer), "lookup"); |
| 423 | 497 | const number = field(ctx.form, "number", 20).toUpperCase(); |
| 424 | 498 | const email = field(ctx.form, "email", 200); |
| 425 | 499 | const values = { number, email }; |
| 426 | 500 | 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); | |
| 428 | 502 | |
| 429 | const order = orders.byNumber(number); | |
| 503 | const found = orders.byNumber(number); | |
| 430 | 504 | 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); | |
| 434 | 511 | |
| 435 | 512 | // The address, not the header: what goes in the trail has to be a fact. |
| 436 | 513 | 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)); | |
| 439 | 516 | } |
| 440 | 517 | |
| 441 | 518 | export async function receiptPage(ctx: Ctx): Promise<Response> { |
| @@ -443,7 +520,7 @@ export async function receiptPage(ctx: Ctx): Promise<Response> { | ||
| 443 | 520 | if (!order) return notFound(ctx); |
| 444 | 521 | const email = forOrder(order.id); |
| 445 | 522 | 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)); | |
| 447 | 524 | } |
| 448 | 525 | |
| 449 | 526 | /** |
src/stripe.test.ts +23 −13
| @@ -259,6 +259,16 @@ class Shopper { | ||
| 259 | 259 | } |
| 260 | 260 | } |
| 261 | 261 | |
| 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 | ||
| 262 | 272 | // --- what the database says -------------------------------------------------- |
| 263 | 273 | |
| 264 | 274 | const variantId = (sku: string): number => |
| @@ -380,7 +390,7 @@ async function admin(): Promise<Shopper> { | ||
| 380 | 390 | // --- the Checkout Session ---------------------------------------------------- |
| 381 | 391 | |
| 382 | 392 | 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(); | |
| 384 | 394 | const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 2 }]); |
| 385 | 395 | const order = orderNamed(placed.number); |
| 386 | 396 | const at = seen.length; |
| @@ -460,7 +470,7 @@ async function issueGiftCard(amount: string, note: string): Promise<string> { | ||
| 460 | 470 | } |
| 461 | 471 | |
| 462 | 472 | test("a discount and a gift card come off what Stripe is asked for", async () => { |
| 463 | const shopper = new Shopper(); | |
| 473 | const shopper = asCustomer(); | |
| 464 | 474 | const code = await issueGiftCard("10.00", "for the Stripe path"); |
| 465 | 475 | const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-1000", quantity: 1 }], ["welcome10", code]); |
| 466 | 476 | const order = orderNamed(placed.number); |
| @@ -501,7 +511,7 @@ test("a discount and a gift card come off what Stripe is asked for", async () => | ||
| 501 | 511 | // --- coming back ------------------------------------------------------------- |
| 502 | 512 | |
| 503 | 513 | test("the shopper coming back settles the order once", async () => { |
| 504 | const shopper = new Shopper(); | |
| 514 | const shopper = asCustomer(); | |
| 505 | 515 | const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 1 }]); |
| 506 | 516 | const started = await pressPayWithCard(shopper, placed); |
| 507 | 517 | const sessionId = sessionIdIn(started.headers.get("location")!); |
| @@ -540,7 +550,7 @@ test("the shopper coming back settles the order once", async () => { | ||
| 540 | 550 | // --- the webhook ------------------------------------------------------------- |
| 541 | 551 | |
| 542 | 552 | test("a signed webhook pays an order that never came back", async () => { |
| 543 | const shopper = new Shopper(); | |
| 553 | const shopper = asCustomer(); | |
| 544 | 554 | const placed = await placeOrder(shopper, [{ sku: "BRW-FILT-100", quantity: 2 }]); |
| 545 | 555 | const started = await pressPayWithCard(shopper, placed); |
| 546 | 556 | const sessionId = sessionIdIn(started.headers.get("location")!); |
| @@ -565,7 +575,7 @@ test("a signed webhook pays an order that never came back", async () => { | ||
| 565 | 575 | }); |
| 566 | 576 | |
| 567 | 577 | test("a webhook after a return leaves exactly one charge", async () => { |
| 568 | const shopper = new Shopper(); | |
| 578 | const shopper = asCustomer(); | |
| 569 | 579 | const placed = await placeOrder(shopper, [{ sku: "KIT-MUG-OAT", quantity: 1 }]); |
| 570 | 580 | const started = await pressPayWithCard(shopper, placed); |
| 571 | 581 | const sessionId = sessionIdIn(started.headers.get("location")!); |
| @@ -595,7 +605,7 @@ test("a webhook after a return leaves exactly one charge", async () => { | ||
| 595 | 605 | // --- a payment that does not cover the order --------------------------------- |
| 596 | 606 | |
| 597 | 607 | test("a short return is written down and the order is not called paid", async () => { |
| 598 | const shopper = new Shopper(); | |
| 608 | const shopper = asCustomer(); | |
| 599 | 609 | const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 2 }]); |
| 600 | 610 | const started = await pressPayWithCard(shopper, placed); |
| 601 | 611 | 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 () | ||
| 646 | 656 | }); |
| 647 | 657 | |
| 648 | 658 | 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(); | |
| 650 | 660 | const placed = await placeOrder(shopper, [{ sku: "BRW-FILT-100", quantity: 1 }]); |
| 651 | 661 | const started = await pressPayWithCard(shopper, placed); |
| 652 | 662 | 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 | ||
| 674 | 684 | }); |
| 675 | 685 | |
| 676 | 686 | test("the exact amount still settles once, and a short event afterwards changes nothing", async () => { |
| 677 | const shopper = new Shopper(); | |
| 687 | const shopper = asCustomer(); | |
| 678 | 688 | const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 2 }]); |
| 679 | 689 | const started = await pressPayWithCard(shopper, placed); |
| 680 | 690 | const sessionId = sessionIdIn(started.headers.get("location")!); |
| @@ -700,7 +710,7 @@ test("the exact amount still settles once, and a short event afterwards changes | ||
| 700 | 710 | // --- refunds ----------------------------------------------------------------- |
| 701 | 711 | |
| 702 | 712 | 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(); | |
| 704 | 714 | const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-1000", quantity: 1 }]); |
| 705 | 715 | const started = await pressPayWithCard(shopper, placed); |
| 706 | 716 | 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 | ||
| 746 | 756 | }); |
| 747 | 757 | |
| 748 | 758 | test("a refund Stripe refuses leaves the ledger exactly as it was", async () => { |
| 749 | const shopper = new Shopper(); | |
| 759 | const shopper = asCustomer(); | |
| 750 | 760 | const placed = await placeOrder(shopper, [{ sku: "COF-GUJI-250", quantity: 1 }]); |
| 751 | 761 | const started = await pressPayWithCard(shopper, placed); |
| 752 | 762 | payAtStripe(sessionIdIn(started.headers.get("location")!)); |
| @@ -777,7 +787,7 @@ test("a refund Stripe refuses leaves the ledger exactly as it was", async () => | ||
| 777 | 787 | // --- when Stripe is not there ------------------------------------------------ |
| 778 | 788 | |
| 779 | 789 | test("Stripe answering 500 leaves the order unpaid and says so in a sentence", async () => { |
| 780 | const shopper = new Shopper(); | |
| 790 | const shopper = asCustomer(); | |
| 781 | 791 | const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 1 }]); |
| 782 | 792 | failing = true; |
| 783 | 793 | 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 | ||
| 792 | 802 | }); |
| 793 | 803 | |
| 794 | 804 | test("Stripe unreachable leaves the order unpaid and says so in a sentence", async () => { |
| 795 | const shopper = new Shopper(); | |
| 805 | const shopper = asCustomer(); | |
| 796 | 806 | const placed = await placeOrder(shopper, [{ sku: "COF-HOUSE-250", quantity: 1 }]); |
| 797 | 807 | |
| 798 | 808 | const at = seen.length; |
| @@ -813,7 +823,7 @@ test("Stripe unreachable leaves the order unpaid and says so in a sentence", asy | ||
| 813 | 823 | }); |
| 814 | 824 | |
| 815 | 825 | test("a live key is refused and no request reaches Stripe", async () => { |
| 816 | const shopper = new Shopper(); | |
| 826 | const shopper = asCustomer(); | |
| 817 | 827 | const placed = await placeOrder(shopper, [{ sku: "BRW-FILT-100", quantity: 1 }]); |
| 818 | 828 | |
| 819 | 829 | // Same order, same database, an app whose key is a live one. |
src/tty.ts +62 −34
| @@ -8,17 +8,23 @@ | ||
| 8 | 8 | * Reaching this screen is not the same as being allowed to use it: every key |
| 9 | 9 | * that changes something — and c, which reads a code out — is an admin's, |
| 10 | 10 | * here as on the web, and so is every tab whose rows are the shop's own |
| 11 | * business. Orders, the outbox, the codes and the cards are all of those: | |
| 12 | * a receipt carries a gift card's code in full, and that code is money to | |
| 13 | * whoever reads it. `src/desk.ts` holds the one table both gates read. | |
| 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. | |
| 14 | 20 | * |
| 15 | 21 | * 1..7 switch between stock, orders, the outbox, discount codes, gift cards, |
| 16 | 22 | * 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. | |
| 22 | 28 | */ |
| 23 | 29 | |
| 24 | 30 | import * as desk from "./desk.ts"; |
| @@ -29,7 +35,7 @@ import { db } from "./db.ts"; | ||
| 29 | 35 | import { chosen } from "./gateway.ts"; |
| 30 | 36 | import { formatCents } from "./money.ts"; |
| 31 | 37 | 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"; | |
| 33 | 39 | import { outbox } from "./mail.ts"; |
| 34 | 40 | import { atKeyboard } from "./who.ts"; |
| 35 | 41 | import { nameOf, recent as recentChanges, type Reading } from "./changes.ts"; |
| @@ -38,7 +44,11 @@ import type { GiftCard, Order, TaxRate } from "./db.ts"; | ||
| 38 | 44 | const person = atKeyboard(); |
| 39 | 45 | const isAdmin = person.role === "admin"; |
| 40 | 46 | 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`; | |
| 42 | 52 | const whoLine = |
| 43 | 53 | person.role === "anon" |
| 44 | 54 | ? `the forge did not name you (anonymous)${onlyReads}` |
| @@ -77,8 +87,9 @@ let message = ""; | ||
| 77 | 87 | // the same needle /admin/orders takes. |
| 78 | 88 | let filter = ""; |
| 79 | 89 | |
| 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); | |
| 82 | 93 | |
| 83 | 94 | const rowsFor = (): unknown[] => |
| 84 | 95 | tab === "stock" |
| @@ -101,9 +112,21 @@ const say = (done: desk.Done): string => (done.ok ? done.message : `${esc}33m${d | ||
| 101 | 112 | /** The refusal the desk never gets to give, because a non-admin never reaches it. */ |
| 102 | 113 | const notYours = say({ ok: false, message: "That is an admin's to do." }); |
| 103 | 114 | |
| 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 | ||
| 104 | 127 | /** |
| 105 | 128 | * 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. | |
| 107 | 130 | */ |
| 108 | 131 | const hints = (): string => |
| 109 | 132 | [ |
| @@ -117,7 +140,7 @@ const hints = (): string => | ||
| 117 | 140 | /** The tab bar. A tab this person may not read is named but marked closed. */ |
| 118 | 141 | const tabBar = (): string => |
| 119 | 142 | TABS.map((name, i) => { |
| 120 | const closed = !isAdmin && desk.needsAdminToRead(name); | |
| 143 | const closed = !desk.canRead(name, person); | |
| 121 | 144 | const text = `${i + 1} ${name}${closed ? "·" : ""}`; |
| 122 | 145 | return name === tab ? bold(text) : dim(text); |
| 123 | 146 | }).join(" "); |
| @@ -127,6 +150,11 @@ const windowOf = (row: { starts_at: string | null; expires_at: string | null }): | ||
| 127 | 150 | |
| 128 | 151 | function detail(order: Order): string[] { |
| 129 | 152 | 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 | ); | |
| 130 | 158 | for (const item of itemsOf(order.id)) |
| 131 | 159 | lines.push( |
| 132 | 160 | ` ${item.product_title} — ${item.variant_name} x${item.quantity} ${formatCents( |
| @@ -293,32 +321,32 @@ process.stdin.on("data", (key: string) => { | ||
| 293 | 321 | const count = rowsFor().length; |
| 294 | 322 | if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, count - 1)); |
| 295 | 323 | 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. | |
| 299 | 326 | const asTab = TABS[Number(key) - 1]; |
| 300 | if (asTab && !isAdmin && desk.needsAdminToRead(asTab)) message = notYours; | |
| 327 | if (asTab && !desk.canRead(asTab, person)) message = notForYou; | |
| 301 | 328 | 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")) | |
| 307 | 335 | prompt = { label: "Move (e.g. 12 a delivery arrived):", text: "", run: moveStock }; |
| 308 | if (key === "/" && tab === "orders" && isAdmin) | |
| 336 | if (key === "/" && tab === "orders" && may("/")) | |
| 309 | 337 | 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")) | |
| 312 | 340 | 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")) | |
| 314 | 342 | 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")) { | |
| 316 | 344 | const code = discounts.withUse()[cursor]; |
| 317 | 345 | message = say(code ? desk.toggleDiscount(code.id) : { ok: false, message: "No code selected." }); |
| 318 | 346 | } |
| 319 | if (key === "i" && tab === "cards" && isAdmin) | |
| 347 | if (key === "i" && tab === "cards" && may("i")) | |
| 320 | 348 | 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")) { | |
| 322 | 350 | const card = giftcards.all()[cursor]; |
| 323 | 351 | prompt = { |
| 324 | 352 | label: card && card.active !== 1 ? "Bring it back — reason:" : "Void it — reason:", |
| @@ -326,11 +354,11 @@ process.stdin.on("data", (key: string) => { | ||
| 326 | 354 | run: (value) => say(card ? desk.toggleCard(card.id, value) : { ok: false, message: "No card selected." }), |
| 327 | 355 | }; |
| 328 | 356 | } |
| 329 | if (key === "e" && tab === "tax" && isAdmin) | |
| 357 | if (key === "e" && tab === "tax" && may("e")) | |
| 330 | 358 | 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")) | |
| 332 | 360 | 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")) { | |
| 334 | 362 | const rate = tax.rates()[cursor]; |
| 335 | 363 | prompt = { |
| 336 | 364 | 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) => { | ||
| 338 | 366 | run: (value) => say(rate ? desk.removeRate(rate.id, value) : { ok: false, message: "No rate selected." }), |
| 339 | 367 | }; |
| 340 | 368 | } |
| 341 | if (key === "h" && tab === "stock" && isAdmin) | |
| 369 | if (key === "h" && tab === "stock" && may("h")) | |
| 342 | 370 | message = history(8) |
| 343 | 371 | .map((entry) => dim(`${entry.at} ${entry.sku} ${entry.delta > 0 ? "+" : ""}${entry.delta} ${entry.reason}`)) |
| 344 | 372 | .join("\r\n"); |
src/units.test.ts +94 −22
| @@ -24,6 +24,7 @@ const giftcards = await import("./giftcards.ts"); | ||
| 24 | 24 | const live = await import("./live.ts"); |
| 25 | 25 | const tax = await import("./tax.ts"); |
| 26 | 26 | const orders = await import("./orders.ts"); |
| 27 | const { getAnswers } = await import("./routes.ts"); | |
| 27 | 28 | const dbModule = await import("./db.ts"); |
| 28 | 29 | const { db, forgetPasswordSettings, getSetting, setSetting } = dbModule; |
| 29 | 30 | |
| @@ -532,7 +533,7 @@ test("every key that does the desk's work needs an admin, and is offered to nobo | ||
| 532 | 533 | const shopper = { role: "user", id: "7", name: "nora" } as const; |
| 533 | 534 | const anon = { role: "anon" } as const; |
| 534 | 535 | |
| 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. | |
| 536 | 537 | expect(desk.needsAdmin("cards", "i")).toBe(true); |
| 537 | 538 | expect(desk.needsAdmin("cards", "v")).toBe(true); |
| 538 | 539 | 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 | ||
| 544 | 545 | expect(desk.needsAdmin("stock", "s")).toBe(true); |
| 545 | 546 | expect(desk.needsAdmin("orders", "r")).toBe(true); |
| 546 | 547 | 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); | |
| 547 | 554 | |
| 548 | 555 | // Moving and switching tabs are not the desk's, and a key is only the |
| 549 | 556 | // desk's on the tab it belongs to. |
| 550 | 557 | for (const tab of desk.TABS) for (const key of ["j", "k", "q", "1", "6"]) expect(desk.needsAdmin(tab, key)).toBe(false); |
| 551 | 558 | expect(desk.needsAdmin("outbox", "i")).toBe(false); |
| 552 | 559 | expect(desk.needsAdmin("stock", "n")).toBe(false); |
| 560 | expect(desk.knows("stock", "n")).toBe(false); | |
| 561 | expect(desk.knows("orders", "r")).toBe(true); | |
| 553 | 562 | |
| 554 | 563 | 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. | |
| 557 | 566 | 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([]); | |
| 562 | 573 | expect(desk.offered(tab, anon)).toEqual([]); |
| 563 | 574 | } |
| 564 | 575 | expect(desk.offered("cards", admin)).toContain("i issue a card"); |
| 576 | expect(desk.offered("orders", shopper)).toEqual(["/ find"]); | |
| 565 | 577 | }); |
| 566 | 578 | |
| 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", () => { | |
| 568 | 580 | const admin = { role: "admin", id: "1", name: "root" } as const; |
| 569 | 581 | const shopper = { role: "user", id: "7", name: "nora" } as const; |
| 570 | 582 | const anon = { role: "anon" } as const; |
| 571 | 583 | |
| 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 | } | |
| 579 | 592 | // 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); | |
| 582 | 598 | |
| 583 | 599 | expect(desk.visible(admin)).toEqual([...desk.TABS]); |
| 584 | expect(desk.visible(shopper)).toEqual(["stock", "tax"]); | |
| 600 | expect(desk.visible(shopper)).toEqual(["stock", "orders", "tax"]); | |
| 585 | 601 | expect(desk.visible(anon)).toEqual(["stock", "tax"]); |
| 586 | 602 | |
| 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([]); | |
| 591 | 650 | }); |
| 592 | 651 | |
| 593 | 652 | 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", () => { | ||
| 757 | 816 | elsewhere.close(false); |
| 758 | 817 | } |
| 759 | 818 | }); |
| 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 @@ | ||
| 1 | 1 | /** The identity check, on its own: what the forge signs, and what it does not. */ |
| 2 | 2 | |
| 3 | 3 | 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"; | |
| 5 | 5 | |
| 6 | 6 | const KEY = "a-key-only-the-forge-and-this-app-know"; |
| 7 | 7 | |
| @@ -105,13 +105,25 @@ test("a page the token opened comes back as one that opens without it", () => { | ||
| 105 | 105 | const at = (href: string) => returnTo(new URL(href, "https://bazaar.example")); |
| 106 | 106 | // /pay/1001 without its token is a 404, so the return is "Find an order", |
| 107 | 107 | // 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); | |
| 111 | 111 | // an empty token opened nothing: that page is still its own return |
| 112 | 112 | expect(at("/orders/1001?token=")).toBe("/orders/1001"); |
| 113 | 113 | }); |
| 114 | 114 | |
| 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 | ||
| 115 | 127 | test("a path a browser would read as a host comes back as the root", () => { |
| 116 | 128 | const at = (pathname: string) => { |
| 117 | 129 | 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 | ||
| 91 | 91 | * |
| 92 | 92 | * A page the secret was what opened cannot come back as itself: `/pay/1001` |
| 93 | 93 | * 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. | |
| 96 | 100 | * |
| 97 | 101 | * `//evil.com/x` is a path to us and an off-origin URL to a browser, so a |
| 98 | 102 | * 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 | ||
| 102 | 106 | const SECRET_PARAMS = ["token"]; |
| 103 | 107 | |
| 104 | 108 | /** |
| 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. | |
| 109 | 114 | */ |
| 110 | export const WITHOUT_SECRET = "/orders"; | |
| 115 | export const SOMEWHERE_THAT_ANSWERS = "/orders"; | |
| 111 | 116 | |
| 112 | export function returnTo(url: URL): string { | |
| 117 | export function returnTo(url: URL, answersGet = true): string { | |
| 113 | 118 | if (!/^\/($|[^/\\])/.test(url.pathname)) return "/"; |
| 114 | 119 | const query = new URLSearchParams(url.search); |
| 115 | 120 | 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; | |
| 117 | 122 | for (const name of SECRET_PARAMS) query.delete(name); |
| 118 | 123 | const search = query.toString(); |
| 119 | 124 | const to = search === "" ? url.pathname : `${url.pathname}?${search}`; |
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.tswithFORGE_APP_USER_ROLE=user. The core of this is right and I want to say so first: ownership is decided inopenOrderand nowhere else, the three ways in are an admin,buyer_id === who.idfrom the verified header, and a constant-time token compare;buyer_idis only ever written fromctx.whoinplaceOrder;forBuyeris parameterised with%/_escaped and can only narrow; the anonPOST /checkoutwrites no row, takes no stock and redeems no code (I confirmed on the preview: 303 to/checkout, cart intact);/orders/findnow refuses an owned order in a miss's words, after the same digest, with nothing written to its trail; every page iscache-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 — asnoraI got stock/orders/tax,3answered "That is an admin's to read." without drawing the tab,rwas refused,/was offered — anddesk.ordersForis 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.
ordersPagepassespath: returnTo(ctx.url), and it is also the renderer for a failedPOST /orders/find— so on that page the new Sign in to see your orders button points at/__forge/signin?to=%2Forders%2Ffind, andGET /orders/findis 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/orderspage, so the fix is to stop deriving it: passpath: "/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
54055famergesdc3c613, but main is nowb40cb34— #37, #48 and #53 landed since, and the forge reports conflicts inREADME.mdandsrc/tty.ts. #53 rewrotedesk.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-runbun test, and re-check the terminal gate by hand afterwards.Smaller, take or leave:
desk.needsAdminis now called only fromunits.test.ts;tty.tsusesknows/mayPress. Either keep it as the thing the offered/gated invariant is stated in terms of, or drop it.payMockand 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 onWITHOUT_SECRETstill describes "Find an order" listing "the orders this browser placed"; that page is now an account's list plus a guest half./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.