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

A shopper's cart follows their account, not the browser #79

Merged opened this

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

Merged

ledger/bazaar:ledger/39-a-shopper-s-cart-follows-their-account-n → main 3 commits fork 2 threads · 2 unresolved

  • Merged. ledger/39-a-shopper-s-cart-follows-their-account-n was merged into main as a671b79.

Review

Sign in to review this change.
9 files changed +555 −23
README.md +47 −3
@@ -1,7 +1,7 @@
11 # bazaar
22
33 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,
55 tax by where the parcel goes, a gift card balance window, checkout with an
66 address, a payment step, receipts, and an `/admin` for products, inventory,
77 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
212212 their buyer with no `?token=` at all, and a signed-in stranger gets the same
213213 404 a stranger has always got.
214214
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
215254 ### What the access token is still for
216255
217256 It is not dead code. The orders placed before accounts existed — `buyer_id` is
@@ -516,7 +555,8 @@ saying so.
516555 | `src/db.ts` | The schema, and `open(dir)`: a directory in, a database out |
517556 | `src/fresh.ts` | A database of a test file's own, opened before the file imports anything that queries |
518557 | `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 |
520560 | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is |
521561 | `src/who.ts` `src/session.ts` | Who the forge says is asking; the session that carries the CSRF token |
522562 | `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
560600 rate limit answers with. The shopper most likely to meet one is the one whose
561601 tab sat open until the session rolled over, mid-purchase: that page links back
562602 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`
564608 page says the shop failed and nothing else — no message, no path, no stack;
565609 `console.error` keeps the whole exception for the log.
566610
src/cart.ts +20 −7
@@ -1,6 +1,10 @@
11 /**
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.
48 *
59 * The cookie carries ids only. Prices, titles and stock are read from the
610 * database on every render — a cookie a shopper edited can change what is in
@@ -17,7 +21,8 @@ import { sign, unsign } from "./sign.ts";
1721 import { NONE, type Applied } from "./codes.ts";
1822
1923 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;
2126 const MAX_QUANTITY = 99;
2227
2328 export type CartLine = { variantId: number; quantity: number };
@@ -152,15 +157,23 @@ export function decode(cookieValue: string | undefined, secret: string): CartLin
152157 return lines;
153158 }
154159
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[] {
156169 const next = lines.filter((line) => line.variantId !== variantId);
157170 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);
159172 }
160173
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[] {
162175 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);
164177 }
165178
166179 /**
src/context.ts +6
@@ -36,6 +36,12 @@ export type Ctx = {
3636 * else; no handler reads the request's stream itself.
3737 */
3838 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 */
3945 cart: CartLine[];
4046 /** The discount and gift card codes this browser is claiming. */
4147 codes: Applied;
src/db.ts +17
@@ -139,6 +139,23 @@ CREATE TABLE IF NOT EXISTS session (
139139 seen_at TEXT NOT NULL DEFAULT (datetime('now'))
140140 );
141141
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
142159 CREATE TABLE IF NOT EXISTS setting (
143160 key TEXT PRIMARY KEY,
144161 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;
outdated

x

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 {
4343 this.person = person;
4444 }
4545
46 /** The same browser with nobody vouching for it any more. */
47 signOut(): void {
48 this.person = undefined;
49 }
50
4651 private forge(): Record<string, string> {
4752 return identityHeaders(this.person);
4853 }
@@ -908,6 +913,163 @@ test("a cart filled as a guest becomes that account's order once they sign in",
908913 limit.forget();
909914 });
910915
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
9111073 test("a guest order from before accounts keeps its story: the token, and the form", async () => {
9121074 limit.forget();
9131075 // 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 @@
1616
1717 import { dataDir, db, forgetPasswordSettings } from "./db.ts";
1818 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";
2021 import { decode as decodeCodes, COOKIE as CODES_COOKIE } from "./codes.ts";
2122 import { CSS, CSS_PATH } from "./style.ts";
2223 import {
@@ -246,12 +247,16 @@ export const server = Bun.serve({
246247 // gets it as ctx.body. A refusal comes back as the status to send.
247248 //
248249 // 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.
252253 // 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)) });
255260 let form: FormData | null = null;
256261 let body: string | null = null;
257262 if (method === "POST" && found.route.raw) {
@@ -300,6 +305,14 @@ function context(
300305 outgoing: string[],
301306 self: { requestIP(request: Request): { address: string } | null },
302307 ): 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:") }));
303316 return {
304317 request,
305318 url,
@@ -309,7 +322,7 @@ function context(
309322 backTo,
310323 form,
311324 body,
312 cart: decodeCart(cookies[CART_COOKIE], secret),
325 cart: held.lines,
313326 codes: decodeCodes(cookies[CODES_COOKIE], secret),
314327 secret,
315328 cookies,
@@ -324,10 +337,12 @@ function context(
324337 };
325338 }
326339
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.
330344 db.query("DELETE FROM session WHERE seen_at < datetime('now', '-30 days')").run();
345 sweepCarts();
331346 forgetPasswordSettings();
332347
333348 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";
1515 import * as discounts from "./discounts.ts";
1616 import * as gateway from "./gateway.ts";
1717 import * as giftcards from "./giftcards.ts";
18 import * as heldcart from "./heldcart.ts";
1819 import * as limit from "./ratelimit.ts";
1920 import * as mine from "./mine.ts";
2021 import * as orders from "./orders.ts";
@@ -52,11 +53,22 @@ const jar = (ctx: Ctx, name: string, value: string): string =>
5253 ctx.secure ? "; Secure" : ""
5354 }`;
5455
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 */
5563 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);
5766 ctx.cart = lines;
5867 };
5968
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
6072 const saveCodes = (ctx: Ctx, applied: codes.Applied): void => {
6173 ctx.setCookie(jar(ctx, codes.COOKIE, codes.encode(applied, ctx.secret)));
6274 ctx.codes = applied;
@@ -146,14 +158,14 @@ export function addToCart(ctx: Ctx): Response {
146158 const mismatched = named !== null && named.id !== variant!.product_id;
147159 if (!variant || variant.active !== 1 || quantity < 1 || mismatched)
148160 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)));
150162 return redirect("/cart");
151163 }
152164
153165 export function updateCart(ctx: Ctx): Response {
154166 const variantId = intField(ctx.form, "variant");
155167 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)));
157169 return redirect("/cart");
158170 }
159171
src/units.test.ts +100
@@ -10,6 +10,7 @@ const dataDir = await freshData("units");
1010
1111 const { dueCents, formatCents, giftCardSpend, parseCents, parseQuantity, sumLines } = await import("./money.ts");
1212 const { encode, decode, put, add, quote, giftCardNote } = await import("./cart.ts");
13 const heldcart = await import("./heldcart.ts");
1314 const { sign, unsign, loadSecret } = await import("./sign.ts");
1415 const { apiBase, verifyWebhook, chosen, minimumCents } = await import("./gateway.ts");
1516 const limit = await import("./ratelimit.ts");
@@ -100,6 +101,105 @@ test("cart lines are replaced, added and capped", () => {
100101 expect(put([], 1, 500)).toEqual([{ variantId: 1, quantity: 99 }]);
101102 });
102103
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
103203 test("an empty cart quotes to nothing, with no shipping", () => {
104204 const empty = quote([]);
105205 expect(empty.subtotal_cents).toBe(0);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs