Public: anyone can read this; the crew can change it.
A shopper's cart follows their account, not the browser #79
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/39-a-shopper-s-cart-follows-their-account-n → main 3 commits fork 2 threads · 2 unresolved
9 files
changed +555
−23
README.md +47 −3
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # bazaar |
| 2 | 2 | |
| 3 | 3 | A small shop: a catalog with categories and search, product pages with |
| 4 | variants and stock, a cart in a signed cookie, discount codes and gift cards, | |
| 4 | variants and stock, a cart that follows the shopper, 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 | 7 | codes, cards, tax and orders with refunds. Looking around and filling a cart |
| @@ -212,6 +212,45 @@ constant time. So `/orders/<number>`, its receipt and `/pay/<number>` open for | ||
| 212 | 212 | their buyer with no `?token=` at all, and a signed-in stranger gets the same |
| 213 | 213 | 404 a stranger has always got. |
| 214 | 214 | |
| 215 | ### Where a cart lives, and for how long | |
| 216 | ||
| 217 | A cart follows the shopper, not the browser. Which of the two places it is in | |
| 218 | is decided once at the front door, in `src/heldcart.ts`, and no handler asks: | |
| 219 | `ctx.cart` is the same `CartLine[]` either way. | |
| 220 | ||
| 221 | | Who is asking | Where the cart is | How long it is kept | | |
| 222 | | --- | --- | --- | | |
| 223 | | A guest | A signed `cart` cookie, ids and quantities only, at most 20 lines | 30 days, or until the browser drops it | | |
| 224 | | An account | Rows in `cart_line`, keyed on `(buyer_id, variant_id)` — the same forge id that owns their orders | Each line 90 days from the last time it moved; swept at boot beside the sessions | | |
| 225 | ||
| 226 | Signing in is the one moment the two meet. On the first request after the | |
| 227 | forge starts vouching for a shopper, the cookie's lines are merged into the | |
| 228 | account's and the cookie is cleared, so from then on a signed-in cart writes | |
| 229 | rows and no cookie at all. A variant in both carts keeps **the larger | |
| 230 | quantity, never the sum** — so a reload, a prefetch or two tabs racing cannot | |
| 231 | double a cart, and the merge is safe to run from anywhere. It runs only for an | |
| 232 | identity `src/who.ts` verified; there is no other way to name a buyer. | |
| 233 | ||
| 234 | Because the cart is the account's, the same account in two browsers is looking | |
| 235 | at one cart, and signing out leaves the browser empty without touching a line | |
| 236 | of what the account holds. Filling a cart while signed out and signing in | |
| 237 | again merges it back in beside what was waiting. | |
| 238 | ||
| 239 | The 90 days are each line's own: only a quantity that actually moved rewrites | |
| 240 | `updated_at`, so a line put in on day 1 of a cart still being used on day 80 | |
| 241 | goes on day 91 with the rest of that cart untouched. That is a choice and not | |
| 242 | an oversight — the stamp is also the order the cart is read back in, so | |
| 243 | touching every row on every save would reshuffle the page each time a shopper | |
| 244 | changed a quantity. Ninety days is long after a cart has been abandoned or | |
| 245 | bought. | |
| 246 | ||
| 247 | The boundary is the same wherever the lines are kept: a quantity is clamped to | |
| 248 | 1..99, a variant that has gone inactive or out of the catalog drops out of the | |
| 249 | quote when the cart is priced, and one buyer may hold at most 50 lines — a | |
| 250 | table a stranger can grow is a table with a cap. A row is ids and quantities | |
| 251 | and nothing else; what a cart costs is read from the catalog every time it is | |
| 252 | quoted, so a row can change what is in a cart and never what it is worth. | |
| 253 | ||
| 215 | 254 | ### What the access token is still for |
| 216 | 255 | |
| 217 | 256 | It is not dead code. The orders placed before accounts existed — `buyer_id` is |
| @@ -516,7 +555,8 @@ saying so. | ||
| 516 | 555 | | `src/db.ts` | The schema, and `open(dir)`: a directory in, a database out | |
| 517 | 556 | | `src/fresh.ts` | A database of a test file's own, opened before the file imports anything that queries | |
| 518 | 557 | | `src/catalog.ts` `src/inventory.ts` `src/orders.ts` | Products, the stock ledger, orders and refunds | |
| 519 | | `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 | | |
| 558 | | `src/cart.ts` `src/heldcart.ts` | A guest's cart in a signed cookie and what it comes to; the cart an account carries in `cart_line`, and which of the two this request has | | |
| 559 | | `src/codes.ts` `src/mine.ts` `src/money.ts` `src/sign.ts` | The codes cookie, the guest orders this browser found, integer cents, HMACs | | |
| 520 | 560 | | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is | |
| 521 | 561 | | `src/who.ts` `src/session.ts` | Who the forge says is asking; the session that carries the CSRF token | |
| 522 | 562 | | `src/orderlink.ts` | Where an order lives for the person being handed the link: with its token, or without it | |
| @@ -560,7 +600,11 @@ stale-form `403`, `415`, `400`, `413`, `408` and `500` — as does the `429` a | ||
| 560 | 600 | rate limit answers with. The shopper most likely to meet one is the one whose |
| 561 | 601 | tab sat open until the session rolled over, mid-purchase: that page links back |
| 562 | 602 | to `/cart` and to `/`, and it carries the new session cookie, so going back and |
| 563 | sending the form again works rather than failing the same way twice. The `500` | |
| 603 | sending the form again works rather than failing the same way twice. Its | |
| 604 | header counts that cart, and for a signed-in shopper the cart is rows, so the | |
| 605 | count is read with `peek` in `src/heldcart.ts` — the lines `cartFor` would | |
| 606 | answer with, cookie folded in, and not one row written: a request that was | |
| 607 | refused leaves the shop exactly as it found it. The `500` | |
| 564 | 608 | page says the shop failed and nothing else — no message, no path, no stack; |
| 565 | 609 | `console.error` keeps the whole exception for the log. |
| 566 | 610 |
src/cart.ts +20 −7
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | /** |
| 2 | * The cart: variant ids and quantities, held in a signed cookie, so the | |
| 3 | * server keeps no state for a shopper who never buys anything. | |
| 2 | * The cart: variant ids and quantities, and what they come to. | |
| 3 | * | |
| 4 | * A guest's lines live in a signed cookie, so the server keeps no state for a | |
| 5 | * shopper who never buys anything. An account's live in `cart_line` and are | |
| 6 | * src/heldcart.ts's to read and write; both kinds are the same `CartLine[]` | |
| 7 | * and are quoted here by the same `quote`, so no handler knows which it has. | |
| 4 | 8 | * |
| 5 | 9 | * The cookie carries ids only. Prices, titles and stock are read from the |
| 6 | 10 | * database on every render — a cookie a shopper edited can change what is in |
| @@ -17,7 +21,8 @@ import { sign, unsign } from "./sign.ts"; | ||
| 17 | 21 | import { NONE, type Applied } from "./codes.ts"; |
| 18 | 22 | |
| 19 | 23 | export const COOKIE = "cart"; |
| 20 | const MAX_LINES = 20; | |
| 24 | /** How many lines fit in the cookie, which is what a guest's cart is. */ | |
| 25 | export const MAX_LINES = 20; | |
| 21 | 26 | const MAX_QUANTITY = 99; |
| 22 | 27 | |
| 23 | 28 | export type CartLine = { variantId: number; quantity: number }; |
| @@ -152,15 +157,23 @@ export function decode(cookieValue: string | undefined, secret: string): CartLin | ||
| 152 | 157 | return lines; |
| 153 | 158 | } |
| 154 | 159 | |
| 155 | export function put(lines: CartLine[], variantId: number, quantity: number): CartLine[] { | |
| 160 | /** | |
| 161 | * A line set to a quantity, 1..99, or removed at zero. `cap` is how many lines | |
| 162 | * the cart this one belongs to may hold: the cookie's twenty for a guest, the | |
| 163 | * table's fifty for an account — see src/heldcart.ts. It is a parameter and | |
| 164 | * not a constant because the same lines are edited by the same handlers | |
| 165 | * wherever they are kept, and an account's cart must not be trimmed to a | |
| 166 | * cookie's size on its way through. | |
| 167 | */ | |
| 168 | export function put(lines: CartLine[], variantId: number, quantity: number, cap = MAX_LINES): CartLine[] { | |
| 156 | 169 | const next = lines.filter((line) => line.variantId !== variantId); |
| 157 | 170 | if (quantity > 0) next.push({ variantId, quantity: Math.min(quantity, MAX_QUANTITY) }); |
| 158 | return next.slice(0, MAX_LINES); | |
| 171 | return next.slice(0, cap); | |
| 159 | 172 | } |
| 160 | 173 | |
| 161 | export function add(lines: CartLine[], variantId: number, quantity: number): CartLine[] { | |
| 174 | export function add(lines: CartLine[], variantId: number, quantity: number, cap = MAX_LINES): CartLine[] { | |
| 162 | 175 | const existing = lines.find((line) => line.variantId === variantId)?.quantity ?? 0; |
| 163 | return put(lines, variantId, existing + quantity); | |
| 176 | return put(lines, variantId, existing + quantity, cap); | |
| 164 | 177 | } |
| 165 | 178 | |
| 166 | 179 | /** |
src/context.ts +6
| @@ -36,6 +36,12 @@ export type Ctx = { | ||
| 36 | 36 | * else; no handler reads the request's stream itself. |
| 37 | 37 | */ |
| 38 | 38 | body: string | null; |
| 39 | /** | |
| 40 | * This shopper's cart. The account's rows when the forge has signed them | |
| 41 | * in, this browser's cookie when it has not — worked out once at the front | |
| 42 | * door, in src/heldcart.ts. The lines are the same shape either way and no | |
| 43 | * handler asks which it was. | |
| 44 | */ | |
| 39 | 45 | cart: CartLine[]; |
| 40 | 46 | /** The discount and gift card codes this browser is claiming. */ |
| 41 | 47 | codes: Applied; |
src/db.ts +17
| @@ -139,6 +139,23 @@ CREATE TABLE IF NOT EXISTS session ( | ||
| 139 | 139 | seen_at TEXT NOT NULL DEFAULT (datetime('now')) |
| 140 | 140 | ); |
| 141 | 141 | |
| 142 | -- The cart an account carries between browsers: one row per buyer and | |
| 143 | -- variant, keyed on the forge id that owns their orders. A guest's cart is a | |
| 144 | -- signed cookie and touches nothing here. Ids and quantities only — what a | |
| 145 | -- cart costs is read from the catalog every time it is quoted, so a row can | |
| 146 | -- change what is in a cart and never what it is worth. src/heldcart.ts is the | |
| 147 | -- one module that reads or writes it, and holds both caps: fifty lines to a | |
| 148 | -- buyer, and ninety days for a line nobody touches. | |
| 149 | CREATE TABLE IF NOT EXISTS cart_line ( | |
| 150 | buyer_id TEXT NOT NULL, | |
| 151 | variant_id INTEGER NOT NULL REFERENCES variant(id), | |
| 152 | quantity INTEGER NOT NULL CHECK (quantity > 0 AND quantity <= 99), | |
| 153 | updated_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 154 | PRIMARY KEY (buyer_id, variant_id) | |
| 155 | ) WITHOUT ROWID; | |
| 156 | -- The sweep's whole order: old lines are found, never scanned for. | |
| 157 | CREATE INDEX IF NOT EXISTS cart_line_age ON cart_line(updated_at); | |
| 158 | ||
| 142 | 159 | CREATE TABLE IF NOT EXISTS setting ( |
| 143 | 160 | key TEXT PRIMARY KEY, |
| 144 | 161 | value TEXT NOT NULL |
src/heldcart.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** | |
| 2 | * The cart an account carries. | |
| 3 | * | |
| 4 | * A guest's cart is a signed cookie (src/cart.ts): the server keeps nothing at | |
| 5 | * all for a browser that never buys. An account's cart is rows in `cart_line`, | |
| 6 | * keyed on the same forge id that owns their orders — so a cart filled on a | |
| 7 | * phone is there on a laptop, and signing out leaves the browser empty without | |
| 8 | * touching a thing the account holds. | |
| 9 | * | |
| 10 | * The rows are ids and quantities and nothing else. Prices, titles and stock | |
| 11 | * are read from the catalog when the cart is quoted, exactly as they are for | |
| 12 | * the cookie, so a row can change what is in a cart and never what it costs. | |
| 13 | * | |
| 14 | * A table a stranger can grow is a table with a cap: fifty lines to a buyer, | |
| 15 | * ninety-nine of any one variant, and rows nobody has touched for ninety days | |
| 16 | * are swept at boot beside the sessions. | |
| 17 | */ | |
| 18 | ||
| 19 | import { db } from "./db.ts"; | |
| 20 | import { decode } from "./cart.ts"; | |
| 21 | import type { CartLine } from "./cart.ts"; | |
| 22 | import type { Who } from "./who.ts"; | |
| 23 | ||
| 24 | /** As many lines as one buyer may hold. The cookie's own cap is smaller. */ | |
| 25 | export const MAX_LINES = 50; | |
| 26 | export const MAX_QUANTITY = 99; | |
| 27 | /** How long a line nobody has touched is kept. */ | |
| 28 | export const KEEP_DAYS = 90; | |
| 29 | ||
| 30 | /** Lines as a table can hold them: whole ids, 1..99, each variant once, capped. */ | |
| 31 | export function clean(lines: CartLine[]): CartLine[] { | |
| 32 | const kept: CartLine[] = []; | |
| 33 | for (const line of lines) { | |
| 34 | if (!Number.isInteger(line.variantId) || line.variantId <= 0) continue; | |
| 35 | if (!Number.isInteger(line.quantity) || line.quantity <= 0) continue; | |
| 36 | if (kept.some((held) => held.variantId === line.variantId)) continue; | |
| 37 | kept.push({ variantId: line.variantId, quantity: Math.min(line.quantity, MAX_QUANTITY) }); | |
| 38 | if (kept.length >= MAX_LINES) break; | |
| 39 | } | |
| 40 | return kept; | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * What this account holds, oldest line first — the order they were put in, | |
| 45 | * because only a line whose quantity actually moved has its stamp rewritten. | |
| 46 | */ | |
| 47 | export const linesOf = (buyerId: string): CartLine[] => | |
| 48 | buyerId === "" | |
| 49 | ? [] | |
| 50 | : db | |
| 51 | .query<CartLine, [string, number]>( | |
| 52 | `SELECT variant_id AS variantId, quantity FROM cart_line | |
| 53 | WHERE buyer_id = ?1 ORDER BY updated_at, variant_id LIMIT ?2`, | |
| 54 | ) | |
| 55 | .all(buyerId, MAX_LINES); | |
| 56 | ||
| 57 | /** | |
| 58 | * This account's cart is now these lines: rows that are not in the list go, | |
| 59 | * rows that are stay, and only a quantity that actually changed moves the | |
| 60 | * stamp the sweep reads. A line naming a variant the catalog does not have is | |
| 61 | * dropped rather than refused — the insert selects the variant, so there is no | |
| 62 | * foreign key to fail on. | |
| 63 | */ | |
| 64 | export function save(buyerId: string, lines: CartLine[]): void { | |
| 65 | if (buyerId === "") return; | |
| 66 | const kept = clean(lines); | |
| 67 | db.transaction(() => { | |
| 68 | if (kept.length === 0) db.query("DELETE FROM cart_line WHERE buyer_id = ?").run(buyerId); | |
| 69 | else | |
| 70 | db.query( | |
| 71 | `DELETE FROM cart_line WHERE buyer_id = ? AND variant_id NOT IN (${kept.map(() => "?").join(",")})`, | |
| 72 | ).run(buyerId, ...kept.map((line) => line.variantId)); | |
| 73 | const upsert = db.query( | |
| 74 | `INSERT INTO cart_line (buyer_id, variant_id, quantity) | |
| 75 | SELECT ?1, v.id, ?3 FROM variant v WHERE v.id = ?2 | |
| 76 | ON CONFLICT(buyer_id, variant_id) DO UPDATE | |
| 77 | SET quantity = excluded.quantity, updated_at = datetime('now') | |
| 78 | WHERE quantity <> excluded.quantity`, | |
| 79 | ); | |
| 80 | for (const line of kept) upsert.run(buyerId, line.variantId, line.quantity); | |
| 81 | })(); | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * Fold a browser's lines into the account's, and answer with the result. | |
| 86 | * | |
| 87 | * A variant in both keeps the larger quantity and never the sum, so merging | |
| 88 | * the same cookie twice — a reload, a prefetch, two tabs racing — leaves the | |
| 89 | * cart exactly as it was. When the cap is reached the account's own lines are | |
| 90 | * the ones that stay: a cookie cannot push a cart out of its own table. | |
| 91 | * | |
| 92 | * A cookie that says nothing the cart does not already hold is a read and | |
| 93 | * nothing else: a browser that keeps sending one it was told to drop does not | |
| 94 | * open a write transaction per request for it. | |
| 95 | */ | |
| 96 | export function merge(buyerId: string, lines: CartLine[]): CartLine[] { | |
| 97 | if (buyerId === "") return []; | |
| 98 | const result = folded(linesOf(buyerId), lines); | |
| 99 | if (result.changed) save(buyerId, result.lines); | |
| 100 | return result.lines; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * The fold itself, with no database in it: the account's lines, a browser's, | |
| 105 | * and whether anything moved. The rule lives here once so that a read of a | |
| 106 | * merged cart (`peek`) and a write of one (`merge`) cannot disagree. | |
| 107 | */ | |
| 108 | function folded(held: CartLine[], lines: CartLine[]): { lines: CartLine[]; changed: boolean } { | |
| 109 | const merged = held.map((line) => ({ ...line })); | |
| 110 | let changed = false; | |
| 111 | for (const line of clean(lines)) { | |
| 112 | const found = merged.find((other) => other.variantId === line.variantId); | |
| 113 | if (found === undefined && merged.length < MAX_LINES) { | |
| 114 | merged.push(line); | |
| 115 | changed = true; | |
| 116 | } else if (found !== undefined && line.quantity > found.quantity) { | |
| 117 | found.quantity = line.quantity; | |
| 118 | changed = true; | |
| 119 | } | |
| 120 | } | |
| 121 | return { lines: merged, changed }; | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Where this request's cart comes from, decided once at the front door so no | |
| 126 | * handler has to know which it was. | |
| 127 | * | |
| 128 | * Anonymous: the cookie's lines, and the server holds nothing. Signed in: the | |
| 129 | * account's rows — with the cookie's lines merged in once, on the first | |
| 130 | * request after the forge started vouching for them, and `spent` saying the | |
| 131 | * cookie has done its job and should be cleared. The merge runs for an | |
| 132 | * identity src/who.ts proved and for no other: `who` is the only way in. | |
| 133 | */ | |
| 134 | export function cartFor( | |
| 135 | who: Who, | |
| 136 | cookieValue: string | undefined, | |
| 137 | secret: string, | |
| 138 | ): { lines: CartLine[]; spent: boolean } { | |
| 139 | if (who.role === "anon") return { lines: decode(cookieValue, secret), spent: false }; | |
| 140 | // No cookie, or the empty one left where a cleared cookie was: nothing to | |
| 141 | // merge and nothing to clear, so a signed-in request is one read. | |
| 142 | if (!cookieValue) return { lines: linesOf(who.id), spent: false }; | |
| 143 | // A cookie that was garbage merges nothing and is still cleared: it is a | |
| 144 | // browser's leftover either way. | |
| 145 | return { lines: merge(who.id, decode(cookieValue, secret)), spent: true }; | |
| 146 | } | |
| 147 | ||
| 148 | /** | |
| 149 | * The same lines `cartFor` would answer with, read and nothing written: the | |
| 150 | * account's rows with this browser's cookie folded in, but no row saved and no | |
| 151 | * cookie spent. It is for a page the front door renders before any handler has | |
| 152 | * run — a refusal, where the header still wants the cart's count — because a | |
| 153 | * request that was refused should leave the shop exactly as it found it. | |
| 154 | */ | |
| 155 | export const peek = (who: Who, cookieValue: string | undefined, secret: string): CartLine[] => | |
| 156 | who.role === "anon" | |
| 157 | ? decode(cookieValue, secret) | |
| 158 | : folded(linesOf(who.id), decode(cookieValue, secret)).lines; | |
| 159 | ||
| 160 | /** Lines nobody has touched in KEEP_DAYS. Called at boot, beside the sessions. */ | |
| 161 | export const sweep = (): void => { | |
| 162 | db.query("DELETE FROM cart_line WHERE updated_at < datetime('now', ?)").run(`-${KEEP_DAYS} days`); | |
| 163 | }; |
src/server.test.ts +162
| @@ -43,6 +43,11 @@ class Shopper { | ||
| 43 | 43 | this.person = person; |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | /** The same browser with nobody vouching for it any more. */ | |
| 47 | signOut(): void { | |
| 48 | this.person = undefined; | |
| 49 | } | |
| 50 | ||
| 46 | 51 | private forge(): Record<string, string> { |
| 47 | 52 | return identityHeaders(this.person); |
| 48 | 53 | } |
| @@ -908,6 +913,163 @@ test("a cart filled as a guest becomes that account's order once they sign in", | ||
| 908 | 913 | limit.forget(); |
| 909 | 914 | }); |
| 910 | 915 | |
| 916 | /** The lines an account is holding, straight out of the table. */ | |
| 917 | const cartRowsOf = (buyerId: string): { variant_id: number; quantity: number }[] => | |
| 918 | db | |
| 919 | .query<{ variant_id: number; quantity: number }, [string]>( | |
| 920 | "SELECT variant_id, quantity FROM cart_line WHERE buyer_id = ?1 ORDER BY variant_id", | |
| 921 | ) | |
| 922 | .all(buyerId); | |
| 923 | ||
| 924 | /** Every Set-Cookie this response wrote for the cart, cleared one or not. */ | |
| 925 | const cartCookies = (response: Response): string[] => | |
| 926 | response.headers.getSetCookie().filter((line) => line.startsWith("cart=")); | |
| 927 | ||
| 928 | test("a cart filled as a guest is the account's own once they sign in, and writes rows from then on", async () => { | |
| 929 | const shopper = new Shopper(); | |
| 930 | await addToCart(shopper, "COF-GUJI-250", 2); | |
| 931 | const asGuest = shopper.jar.get("cart")!; | |
| 932 | expect(asGuest).not.toBe(""); | |
| 933 | ||
| 934 | const person = account(); | |
| 935 | shopper.signIn(person); | |
| 936 | // The first page after signing in is the merge: the browser's line is the | |
| 937 | // account's now, and the cookie that carried it is spent. | |
| 938 | const cart = await shopper.get("/cart"); | |
| 939 | expect(await cart.text()).toContain("Ethiopia Guji"); | |
| 940 | expect(cartRowsOf(person.id)).toEqual([{ variant_id: variantId("COF-GUJI-250"), quantity: 2 }]); | |
| 941 | expect(cartCookies(cart)).toEqual(["cart=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"]); | |
| 942 | expect(shopper.jar.get("cart")).toBe(""); | |
| 943 | ||
| 944 | // And a signed-in shopper's cart is rows and no cookie at all. | |
| 945 | const added = await shopper.post( | |
| 946 | "/cart/add", | |
| 947 | { variant: String(variantId("BRW-FILT-100")), quantity: "1" }, | |
| 948 | Shopper.tokenIn(await shopper.page("/p/paper-filters")), | |
| 949 | ); | |
| 950 | expect(added.status).toBe(303); | |
| 951 | expect(cartCookies(added)).toEqual([]); | |
| 952 | expect(cartRowsOf(person.id)).toHaveLength(2); | |
| 953 | }); | |
| 954 | ||
| 955 | test("the same account in two browsers is looking at one cart", async () => { | |
| 956 | const person = account(); | |
| 957 | const phone = new Shopper(person); | |
| 958 | await addToCart(phone, "COF-GUJI-250", 1); | |
| 959 | ||
| 960 | // A different browser, no cookies of its own, the same account. | |
| 961 | const laptop = new Shopper(person); | |
| 962 | const seen = await laptop.page("/cart"); | |
| 963 | expect(seen).toContain("Ethiopia Guji"); | |
| 964 | expect(laptop.jar.has("cart")).toBe(false); | |
| 965 | ||
| 966 | // What the laptop changes the phone is holding, because it is one cart. | |
| 967 | const updated = await laptop.post( | |
| 968 | "/cart/update", | |
| 969 | { variant: String(variantId("COF-GUJI-250")), quantity: "4" }, | |
| 970 | Shopper.tokenIn(seen), | |
| 971 | ); | |
| 972 | expect(updated.status).toBe(303); | |
| 973 | expect(await phone.page("/cart")).toContain('name="quantity" value="4"'); | |
| 974 | expect(cartRowsOf(person.id)).toEqual([{ variant_id: variantId("COF-GUJI-250"), quantity: 4 }]); | |
| 975 | }); | |
| 976 | ||
| 977 | test("merging the same cart cookie twice does not double a quantity", async () => { | |
| 978 | const shopper = new Shopper(); | |
| 979 | await addToCart(shopper, "COF-GUJI-250", 3); | |
| 980 | const cookie = shopper.jar.get("cart")!; | |
| 981 | const person = account(); | |
| 982 | shopper.signIn(person); | |
| 983 | await shopper.page("/cart"); | |
| 984 | expect(cartRowsOf(person.id)).toEqual([{ variant_id: variantId("COF-GUJI-250"), quantity: 3 }]); | |
| 985 | ||
| 986 | // The same cookie arriving again — a tab that was open, a prefetch, a back | |
| 987 | // button — merges by the larger quantity and never by the sum. | |
| 988 | for (let again = 0; again < 2; again++) { | |
| 989 | const replayed = await fetch(base + "/cart", { | |
| 990 | headers: { | |
| 991 | cookie: `cart=${cookie}`, | |
| 992 | "sec-fetch-site": "same-origin", | |
| 993 | ...identityHeaders(person), | |
| 994 | }, | |
| 995 | redirect: "manual", | |
| 996 | }); | |
| 997 | expect(replayed.status).toBe(200); | |
| 998 | expect(cartRowsOf(person.id)).toEqual([{ variant_id: variantId("COF-GUJI-250"), quantity: 3 }]); | |
| 999 | } | |
| 1000 | ||
| 1001 | // A cart already the larger of the two keeps its own number. | |
| 1002 | const page = await shopper.page("/cart"); | |
| 1003 | await shopper.post("/cart/update", { variant: String(variantId("COF-GUJI-250")), quantity: "9" }, Shopper.tokenIn(page)); | |
| 1004 | await fetch(base + "/cart", { | |
| 1005 | headers: { cookie: `cart=${cookie}`, "sec-fetch-site": "same-origin", ...identityHeaders(person) }, | |
| 1006 | redirect: "manual", | |
| 1007 | }); | |
| 1008 | expect(cartRowsOf(person.id)).toEqual([{ variant_id: variantId("COF-GUJI-250"), quantity: 9 }]); | |
| 1009 | }); | |
| 1010 | ||
| 1011 | test("a cart survives signing out and signing back in, and the browser is left empty", async () => { | |
| 1012 | const person = account(); | |
| 1013 | const shopper = new Shopper(person); | |
| 1014 | await addToCart(shopper, "KIT-MUG-OAT", 2); | |
| 1015 | expect(cartRowsOf(person.id)).toHaveLength(1); | |
| 1016 | ||
| 1017 | // Signed out, this browser is holding nothing: the account's rows are not | |
| 1018 | // its to see, and no cookie was ever written for them. | |
| 1019 | shopper.signOut(); | |
| 1020 | expect(await shopper.page("/cart")).toContain("Nothing in it yet"); | |
| 1021 | expect(cartRowsOf(person.id)).toHaveLength(1); | |
| 1022 | ||
| 1023 | // A guest cart filled here afterwards is this browser's alone, until the | |
| 1024 | // same account comes back and it merges in beside what was waiting. | |
| 1025 | await addToCart(shopper, "COF-GUJI-250", 1); | |
| 1026 | expect(cartRowsOf(person.id)).toHaveLength(1); | |
| 1027 | ||
| 1028 | shopper.signIn(person); | |
| 1029 | const back = await shopper.page("/cart"); | |
| 1030 | expect(back).toContain("Stoneware Mug"); | |
| 1031 | expect(back).toContain("Ethiopia Guji"); | |
| 1032 | expect(cartRowsOf(person.id)).toEqual([ | |
| 1033 | { variant_id: variantId("COF-GUJI-250"), quantity: 1 }, | |
| 1034 | { variant_id: variantId("KIT-MUG-OAT"), quantity: 2 }, | |
| 1035 | ]); | |
| 1036 | }); | |
| 1037 | ||
| 1038 | test("a refusal at the door counts a signed-in shopper's rows, and writes nothing counting them", async () => { | |
| 1039 | const person = account(); | |
| 1040 | const shopper = new Shopper(person); | |
| 1041 | await addToCart(shopper, "KIT-MUG-OAT", 2); | |
| 1042 | const held = cartRowsOf(person.id); | |
| 1043 | ||
| 1044 | // A form that went stale, from a browser the forge has signed in, and a | |
| 1045 | // guest cookie from before it was: the page is rendered at the door, before | |
| 1046 | // any handler runs, so its header cannot read a Ctx — but the cart it counts | |
| 1047 | // is still this shopper's, which is rows plus what that cookie would fold | |
| 1048 | // in. Three: two on the account, one in the browser. | |
| 1049 | const browser = new Shopper(); | |
| 1050 | await addToCart(browser, "COF-GUJI-250", 1); | |
| 1051 | const guest = browser.jar.get("cart")!; | |
| 1052 | const stale = await knock("/cart/add", { | |
| 1053 | body: new URLSearchParams({ variant: String(variantId("COF-GUJI-250")), quantity: "1", csrf: "not-the-token" }), | |
| 1054 | headers: { | |
| 1055 | cookie: `cart=${guest}`, | |
| 1056 | "sec-fetch-site": "same-origin", | |
| 1057 | ...BROWSER, | |
| 1058 | "content-type": "application/x-www-form-urlencoded", | |
| 1059 | ...identityHeaders(person), | |
| 1060 | }, | |
| 1061 | }); | |
| 1062 | expect(stale.status).toBe(403); | |
| 1063 | const body = await stale.text(); | |
| 1064 | expectShopPage(stale, body); | |
| 1065 | expect(body).toContain('href="/cart">Cart · 3</a>'); | |
| 1066 | ||
| 1067 | // And the request that was refused left the shop as it found it: the fold | |
| 1068 | // was a read, so no row moved and the cookie is not spent by a 403. | |
| 1069 | expect(cartRowsOf(person.id)).toEqual(held); | |
| 1070 | expect(cartCookies(stale)).toEqual([]); | |
| 1071 | }); | |
| 1072 | ||
| 911 | 1073 | test("a guest order from before accounts keeps its story: the token, and the form", async () => { |
| 912 | 1074 | limit.forget(); |
| 913 | 1075 | // A row as it was written before an order had an account: no buyer at all. |
src/server.ts +25 −10
| @@ -16,7 +16,8 @@ | ||
| 16 | 16 | |
| 17 | 17 | import { dataDir, db, forgetPasswordSettings } from "./db.ts"; |
| 18 | 18 | import { fileOf } from "./asset.ts"; |
| 19 | import { decode as decodeCart, COOKIE as CART_COOKIE } from "./cart.ts"; | |
| 19 | import { COOKIE as CART_COOKIE } from "./cart.ts"; | |
| 20 | import { cartFor, peek as peekCart, sweep as sweepCarts } from "./heldcart.ts"; | |
| 20 | 21 | import { decode as decodeCodes, COOKIE as CODES_COOKIE } from "./codes.ts"; |
| 21 | 22 | import { CSS, CSS_PATH } from "./style.ts"; |
| 22 | 23 | import { |
| @@ -246,12 +247,16 @@ export const server = Bun.serve({ | ||
| 246 | 247 | // gets it as ctx.body. A refusal comes back as the status to send. |
| 247 | 248 | // |
| 248 | 249 | // A refusal from here on is a page of the shop: the session about to be |
| 249 | // set, who the forge says is asking and the cart in the cookie are all | |
| 250 | // known, and `outgoing` carries the session cookie with it — so a shopper | |
| 251 | // who goes back and sends the form again is not refused the same way twice. | |
| 250 | // set, who the forge says is asking and this shopper's cart are all known, | |
| 251 | // and `outgoing` carries the session cookie with it — so a shopper who goes | |
| 252 | // back and sends the form again is not refused the same way twice. | |
| 252 | 253 | // Worked out only when one of them happens; the request that is answered |
| 253 | // decodes its cart once, in its Ctx. | |
| 254 | const shell = (): Shell => ({ who, path: backTo, cartCount: cartCount(decodeCart(cookies[CART_COOKIE], secret)) }); | |
| 254 | // opens its cart once, in its Ctx. | |
| 255 | // | |
| 256 | // The count is read with `peek` and not `cartFor`: the cart a signed-in | |
| 257 | // shopper sees is rows, so the header has to read them, but a refused | |
| 258 | // request merges no cookie in and spends none — it writes nothing at all. | |
| 259 | const shell = (): Shell => ({ who, path: backTo, cartCount: cartCount(peekCart(who, cookies[CART_COOKIE], secret)) }); | |
| 255 | 260 | let form: FormData | null = null; |
| 256 | 261 | let body: string | null = null; |
| 257 | 262 | if (method === "POST" && found.route.raw) { |
| @@ -300,6 +305,14 @@ function context( | ||
| 300 | 305 | outgoing: string[], |
| 301 | 306 | self: { requestIP(request: Request): { address: string } | null }, |
| 302 | 307 | ): Ctx { |
| 308 | // Where this shopper's cart comes from, decided once for every handler: the | |
| 309 | // cookie's lines for a guest, the account's rows for somebody the forge | |
| 310 | // vouched for — with the browser's cookie merged into them on the first | |
| 311 | // request after signing in, and then cleared, because a signed-in cart is | |
| 312 | // rows and no cookie. See src/heldcart.ts for why merging twice is safe. | |
| 313 | const held = cartFor(who, cookies[CART_COOKIE], secret); | |
| 314 | if (held.spent) | |
| 315 | outgoing.push(cookie(CART_COOKIE, "", { maxAge: 0, secure: publicOrigin.startsWith("https:") })); | |
| 303 | 316 | return { |
| 304 | 317 | request, |
| 305 | 318 | url, |
| @@ -309,7 +322,7 @@ function context( | ||
| 309 | 322 | backTo, |
| 310 | 323 | form, |
| 311 | 324 | body, |
| 312 | cart: decodeCart(cookies[CART_COOKIE], secret), | |
| 325 | cart: held.lines, | |
| 313 | 326 | codes: decodeCodes(cookies[CODES_COOKIE], secret), |
| 314 | 327 | secret, |
| 315 | 328 | cookies, |
| @@ -324,10 +337,12 @@ function context( | ||
| 324 | 337 | }; |
| 325 | 338 | } |
| 326 | 339 | |
| 327 | // Old carts and abandoned sessions are not worth keeping, and the password | |
| 328 | // this app used to have left two settings that now open nothing. Both are | |
| 329 | // cheap and happen once at boot, after the socket is already listening. | |
| 340 | // Abandoned sessions and the carts nobody came back for are not worth | |
| 341 | // keeping, and the password this app used to have left two settings that now | |
| 342 | // open nothing. All three are cheap and happen once at boot, after the socket | |
| 343 | // is already listening. | |
| 330 | 344 | db.query("DELETE FROM session WHERE seen_at < datetime('now', '-30 days')").run(); |
| 345 | sweepCarts(); | |
| 331 | 346 | forgetPasswordSettings(); |
| 332 | 347 | |
| 333 | 348 | console.log(`bazaar listening on http://${server.hostname}:${server.port} (${env})`); |
src/shop.ts +15 −3
| @@ -15,6 +15,7 @@ import * as codes from "./codes.ts"; | ||
| 15 | 15 | import * as discounts from "./discounts.ts"; |
| 16 | 16 | import * as gateway from "./gateway.ts"; |
| 17 | 17 | import * as giftcards from "./giftcards.ts"; |
| 18 | import * as heldcart from "./heldcart.ts"; | |
| 18 | 19 | import * as limit from "./ratelimit.ts"; |
| 19 | 20 | import * as mine from "./mine.ts"; |
| 20 | 21 | import * as orders from "./orders.ts"; |
| @@ -52,11 +53,22 @@ const jar = (ctx: Ctx, name: string, value: string): string => | ||
| 52 | 53 | ctx.secure ? "; Secure" : "" |
| 53 | 54 | }`; |
| 54 | 55 | |
| 56 | /** | |
| 57 | * A cart goes back where this shopper's cart lives: the account's rows once | |
| 58 | * the forge has signed them in, this browser's cookie otherwise. A signed-in | |
| 59 | * shopper writes no cart cookie at all — the front door cleared it when it | |
| 60 | * merged it in — so signing out leaves the browser empty and the account's | |
| 61 | * rows alone. | |
| 62 | */ | |
| 55 | 63 | const saveCart = (ctx: Ctx, lines: cart.CartLine[]): void => { |
| 56 | ctx.setCookie(jar(ctx, cart.COOKIE, cart.encode(lines, ctx.secret))); | |
| 64 | if (ctx.who.role === "anon") ctx.setCookie(jar(ctx, cart.COOKIE, cart.encode(lines, ctx.secret))); | |
| 65 | else heldcart.save(ctx.who.id, lines); | |
| 57 | 66 | ctx.cart = lines; |
| 58 | 67 | }; |
| 59 | 68 | |
| 69 | /** How many lines the cart in front of this shopper may hold. */ | |
| 70 | const capacity = (ctx: Ctx): number => (ctx.who.role === "anon" ? cart.MAX_LINES : heldcart.MAX_LINES); | |
| 71 | ||
| 60 | 72 | const saveCodes = (ctx: Ctx, applied: codes.Applied): void => { |
| 61 | 73 | ctx.setCookie(jar(ctx, codes.COOKIE, codes.encode(applied, ctx.secret))); |
| 62 | 74 | ctx.codes = applied; |
| @@ -146,14 +158,14 @@ export function addToCart(ctx: Ctx): Response { | ||
| 146 | 158 | const mismatched = named !== null && named.id !== variant!.product_id; |
| 147 | 159 | if (!variant || variant.active !== 1 || quantity < 1 || mismatched) |
| 148 | 160 | return redirect(slug ? `/p/${encodeURIComponent(slug)}` : "/"); |
| 149 | saveCart(ctx, cart.add(ctx.cart, variant.id, quantity)); | |
| 161 | saveCart(ctx, cart.add(ctx.cart, variant.id, quantity, capacity(ctx))); | |
| 150 | 162 | return redirect("/cart"); |
| 151 | 163 | } |
| 152 | 164 | |
| 153 | 165 | export function updateCart(ctx: Ctx): Response { |
| 154 | 166 | const variantId = intField(ctx.form, "variant"); |
| 155 | 167 | const quantity = parseQuantity(ctx.form?.get("quantity"), 99); |
| 156 | if (variantId !== undefined) saveCart(ctx, cart.put(ctx.cart, variantId, quantity)); | |
| 168 | if (variantId !== undefined) saveCart(ctx, cart.put(ctx.cart, variantId, quantity, capacity(ctx))); | |
| 157 | 169 | return redirect("/cart"); |
| 158 | 170 | } |
| 159 | 171 |
src/units.test.ts +100
| @@ -10,6 +10,7 @@ const dataDir = await freshData("units"); | ||
| 10 | 10 | |
| 11 | 11 | const { dueCents, formatCents, giftCardSpend, parseCents, parseQuantity, sumLines } = await import("./money.ts"); |
| 12 | 12 | const { encode, decode, put, add, quote, giftCardNote } = await import("./cart.ts"); |
| 13 | const heldcart = await import("./heldcart.ts"); | |
| 13 | 14 | const { sign, unsign, loadSecret } = await import("./sign.ts"); |
| 14 | 15 | const { apiBase, verifyWebhook, chosen, minimumCents } = await import("./gateway.ts"); |
| 15 | 16 | const limit = await import("./ratelimit.ts"); |
| @@ -100,6 +101,105 @@ test("cart lines are replaced, added and capped", () => { | ||
| 100 | 101 | expect(put([], 1, 500)).toEqual([{ variantId: 1, quantity: 99 }]); |
| 101 | 102 | }); |
| 102 | 103 | |
| 104 | test("a held cart is clamped, capped, and holds only variants that exist", () => { | |
| 105 | const buyer = "u_held_1"; | |
| 106 | const guji = variantId("COF-GUJI-250"); | |
| 107 | const mug = variantId("KIT-MUG-OAT"); | |
| 108 | ||
| 109 | // What a table may hold: whole ids, 1..99 of a variant, each variant once, | |
| 110 | // and never more lines than the cap however many are handed in. | |
| 111 | const many = Array.from({ length: 80 }, (_, i) => ({ variantId: i + 1, quantity: 500 })); | |
| 112 | expect(heldcart.clean(many)).toHaveLength(heldcart.MAX_LINES); | |
| 113 | expect(heldcart.clean(many)[0]).toEqual({ variantId: 1, quantity: 99 }); | |
| 114 | expect(heldcart.clean([{ variantId: 1.5, quantity: 2 }, { variantId: 4, quantity: 0 }])).toEqual([]); | |
| 115 | expect(heldcart.clean([{ variantId: guji, quantity: 1 }, { variantId: guji, quantity: 7 }])).toEqual([ | |
| 116 | { variantId: guji, quantity: 1 }, | |
| 117 | ]); | |
| 118 | ||
| 119 | // A line naming a variant this shop does not have is dropped, not refused. | |
| 120 | heldcart.save(buyer, [{ variantId: guji, quantity: 200 }, { variantId: 999_999, quantity: 1 }]); | |
| 121 | expect(heldcart.linesOf(buyer)).toEqual([{ variantId: guji, quantity: 99 }]); | |
| 122 | ||
| 123 | // Saving is the whole cart: a line that is not in the list is gone. | |
| 124 | heldcart.save(buyer, [{ variantId: guji, quantity: 2 }, { variantId: mug, quantity: 1 }]); | |
| 125 | expect(heldcart.linesOf(buyer)).toEqual([ | |
| 126 | { variantId: guji, quantity: 2 }, | |
| 127 | { variantId: mug, quantity: 1 }, | |
| 128 | ]); | |
| 129 | heldcart.save(buyer, []); | |
| 130 | expect(heldcart.linesOf(buyer)).toEqual([]); | |
| 131 | ||
| 132 | // Nobody's cart is nobody's business: an unverified id writes nothing. | |
| 133 | heldcart.save("", [{ variantId: guji, quantity: 1 }]); | |
| 134 | expect(heldcart.linesOf("")).toEqual([]); | |
| 135 | }); | |
| 136 | ||
| 137 | test("merging a browser's lines into an account's takes the larger quantity, never the sum", () => { | |
| 138 | const buyer = "u_held_2"; | |
| 139 | const guji = variantId("COF-GUJI-250"); | |
| 140 | const mug = variantId("KIT-MUG-OAT"); | |
| 141 | heldcart.save(buyer, [{ variantId: guji, quantity: 2 }]); | |
| 142 | ||
| 143 | const fromCookie = [{ variantId: guji, quantity: 3 }, { variantId: mug, quantity: 1 }]; | |
| 144 | expect(heldcart.merge(buyer, fromCookie)).toEqual([ | |
| 145 | { variantId: guji, quantity: 3 }, | |
| 146 | { variantId: mug, quantity: 1 }, | |
| 147 | ]); | |
| 148 | // The same cookie again is the same cart: that is what makes the merge safe | |
| 149 | // to run from anywhere, including a request that was sent twice. | |
| 150 | expect(heldcart.merge(buyer, fromCookie)).toEqual(heldcart.linesOf(buyer)); | |
| 151 | expect(heldcart.linesOf(buyer)).toEqual([ | |
| 152 | { variantId: guji, quantity: 3 }, | |
| 153 | { variantId: mug, quantity: 1 }, | |
| 154 | ]); | |
| 155 | // A cart already holding more than the cookie claims keeps its own number. | |
| 156 | heldcart.save(buyer, [{ variantId: guji, quantity: 9 }]); | |
| 157 | heldcart.merge(buyer, [{ variantId: guji, quantity: 3 }]); | |
| 158 | expect(heldcart.linesOf(buyer)).toEqual([{ variantId: guji, quantity: 9 }]); | |
| 159 | heldcart.save(buyer, []); | |
| 160 | }); | |
| 161 | ||
| 162 | test("peeking at a cart folds the cookie in and writes not a row", () => { | |
| 163 | const buyer = "u_held_3"; | |
| 164 | const guji = variantId("COF-GUJI-250"); | |
| 165 | const mug = variantId("KIT-MUG-OAT"); | |
| 166 | const secret = "peek-secret"; | |
| 167 | heldcart.save(buyer, [{ variantId: guji, quantity: 2 }]); | |
| 168 | const who = { role: "user", id: buyer, name: "peeker" } as const; | |
| 169 | ||
| 170 | // The same answer `cartFor` would give, and none of the writing: the cookie's | |
| 171 | // line is counted and the account's row for the same variant keeps the larger | |
| 172 | // quantity, exactly as a merge would have left it. | |
| 173 | const cookie = encode([{ variantId: guji, quantity: 1 }, { variantId: mug, quantity: 4 }], secret); | |
| 174 | expect(heldcart.peek(who, cookie, secret)).toEqual([ | |
| 175 | { variantId: guji, quantity: 2 }, | |
| 176 | { variantId: mug, quantity: 4 }, | |
| 177 | ]); | |
| 178 | expect(heldcart.linesOf(buyer)).toEqual([{ variantId: guji, quantity: 2 }]); | |
| 179 | ||
| 180 | // A guest peeks at a cookie and nothing else; garbage in it is no lines. | |
| 181 | expect(heldcart.peek({ role: "anon" }, cookie, secret)).toEqual([ | |
| 182 | { variantId: guji, quantity: 1 }, | |
| 183 | { variantId: mug, quantity: 4 }, | |
| 184 | ]); | |
| 185 | expect(heldcart.peek(who, "not-a-cookie", secret)).toEqual([{ variantId: guji, quantity: 2 }]); | |
| 186 | heldcart.save(buyer, []); | |
| 187 | }); | |
| 188 | ||
| 189 | test("a held line nobody has touched for ninety days is swept", () => { | |
| 190 | const stale = "u_held_stale"; | |
| 191 | const fresh = "u_held_fresh"; | |
| 192 | const guji = variantId("COF-GUJI-250"); | |
| 193 | heldcart.save(stale, [{ variantId: guji, quantity: 1 }]); | |
| 194 | heldcart.save(fresh, [{ variantId: guji, quantity: 1 }]); | |
| 195 | db.query("UPDATE cart_line SET updated_at = datetime('now', '-91 days') WHERE buyer_id = ?1").run(stale); | |
| 196 | ||
| 197 | heldcart.sweep(); | |
| 198 | expect(heldcart.linesOf(stale)).toEqual([]); | |
| 199 | expect(heldcart.linesOf(fresh)).toEqual([{ variantId: guji, quantity: 1 }]); | |
| 200 | heldcart.save(fresh, []); | |
| 201 | }); | |
| 202 | ||
| 103 | 203 | test("an empty cart quotes to nothing, with no shipping", () => { |
| 104 | 204 | const empty = quote([]); |
| 105 | 205 | expect(empty.subtotal_cents).toBe(0); |
x