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

Every refusal at the front door is a page of the shop, with a way back #88

Merged opened this

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

Merged

mason/bazaar:mason/45-every-refusal-at-the-front-door-is-a-pag → main 1 commit fork

  • Merged. mason/45-every-refusal-at-the-front-door-is-a-pag was merged into main as dc14e4f.

Review

Sign in to review this change.
6 files changed +381 −20
README.md +22
@@ -452,6 +452,7 @@ saying so.
452452 | `src/gateway.ts` `src/mail.ts` | Stripe or the mock, the outbox |
453453 | `src/shop.ts` `src/admin.ts` | Handlers for the shop and for `/admin` |
454454 | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents |
455 | `src/pages/oops.ts` | The page a refusal wears: a status and a sentence in, a page of the shop with a way out of it — or the line a machine asked for |
455456 | `src/live.ts` `static/live.js` | The bus: a `live_event` row, and the stream that hands rows out across both processes; the one script, which refetches the `data-live` regions of the page it is on |
456457 | `src/asset.ts` | A file under `static/`, addressed by its contents: the hash in the path has to be the hash of the bytes |
457458 | `src/units.test.ts` `src/live.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` `src/https.test.ts` | The rules on their own, the bus on its own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy, the app booted on an https public address |
@@ -475,6 +476,27 @@ is capped before it is verified and reads the result as `ctx.body`. A POST that
475476 is not a form is `415`, one that is not same-origin or is missing the session's
476477 CSRF token is `403`.
477478
479 ### What a shopper sees when one of those happens
480
481 A refusal is a page of this shop, not a line of plain text on a white page:
482 the header, the stylesheet, one sentence saying what happened, and a way out
483 of it. `src/pages/oops.ts` renders all of them from a status and a sentence,
484 and `src/server.ts` sends every "no" through it — the cross-site `403`, the
485 stale-form `403`, `415`, `400`, `413`, `408` and `500` — as does the `429` a
486 rate limit answers with. The shopper most likely to meet one is the one whose
487 tab sat open until the session rolled over, mid-purchase: that page links back
488 to `/cart` and to `/`, and it carries the new session cookie, so going back and
489 sending the form again works rather than failing the same way twice. The `500`
490 page says the shop failed and nothing else — no message, no path, no stack;
491 `console.error` keeps the whole exception for the log.
492
493 A machine is not dressed up. A caller whose `accept` header does not mention
494 `text/html` gets the same line it always got (`forbidden`, `expected a form`,
495 `that body was too large`), and `POST /webhooks/stripe` — the one route that
496 reads its own body — answers plain text however it asks. Every one of these
497 keeps its status, `cache-control: no-store`, and the security floor, because
498 they all leave through the same `finish()` as every other response.
499
478500 The security floor on every response is in `src/server.ts`: a content security
479501 policy with `script-src 'self'` — the one loosening this app has made, for the
480502 one module under `static/` — and `form-action 'self'` (widened for one
src/http.ts +9
@@ -13,6 +13,15 @@ export const text = (body: string, status = 200): Response =>
1313 export const redirect = (location: string, status: 302 | 303 = 303): Response =>
1414 new Response(null, { status, headers: { location } });
1515
16 /**
17 * Whether the caller asked for a page. A browser navigating says so — every
18 * navigation names text/html in `accept` — and a webhook, a script and the
19 * wildcard curl sends do not. There is no guess here: the header either
20 * mentions text/html or the caller gets a line.
21 */
22 export const wantsHtml = (headers: Headers): boolean =>
23 (headers.get("accept") ?? "").toLowerCase().includes("text/html");
24
1625 /**
1726 * The header a handler uses to say "this response takes a form navigation to
1827 * another origin". The security floor sets `form-action 'self'`, and Chromium
src/pages/oops.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * The page a refusal wears.
3 *
4 * Every "no" the front door says is a page of this shop — the header, the
5 * stylesheet, a sentence in a person's words and a way out of it — and not a
6 * line of plain text on a white page. The shopper most likely to meet one is
7 * the one whose tab sat open until the session rolled over, mid-purchase, with
8 * a full cart, so the way out is a link back to the cart and to the shop.
9 *
10 * A machine is not dressed up: a caller whose `accept` does not mention
11 * text/html gets the line it would have got before, and `respond` is where
12 * that is decided. Nothing here is a guess about the caller — the header is
13 * read for text/html and that is all.
14 *
15 * Nothing here reaches for a `Ctx`: the refusals that happen before one exists
16 * render from what is known at the door and take the defaults for the rest.
17 */
18
19 import { categories } from "../catalog.ts";
20 import { escape, layout } from "../html.ts";
21 import { html, text, wantsHtml } from "../http.ts";
22 import { ANON, type Who } from "../who.ts";
23
24 /** A way out of the page. */
25 export type Way = { href: string; label: string };
26
27 export type Oops = {
28 status: number;
29 /** The heading and the title: what happened, not a status code. */
30 title: string;
31 /** One sentence saying what happened, as plain text; escaped here. */
32 said: string;
33 /** Ways out, in order; the first is the button. Defaults to the shop. */
34 ways?: Way[];
35 /** The line a machine gets instead of the page. Defaults to `said`. */
36 line?: string;
37 };
38
39 /**
40 * What the shell needs and a refusal may not know: who the forge says is
41 * asking, where signing in comes back to, how full the cart is. Anything left
42 * out is a signed-out shopper with an empty cart looking at the shop's front.
43 */
44 export type Shell = { who?: Who; path?: string; cartCount?: number };
45
46 const HOME: Way[] = [{ href: "/", label: "Back to the shop" }];
47
48 /** The cart first, because a refusal mid-purchase is the one that stings. */
49 export const BACK_TO_CART: Way[] = [
50 { href: "/cart", label: "Back to your cart" },
51 { href: "/", label: "The shop" },
52 ];
53
54 const button = (way: Way, first: boolean): string =>
55 `<a class="btn${first ? "" : " ghost"}" href="${escape(way.href)}">${escape(way.label)}</a>`;
56
57 /** The refusal as a page of this shop. */
58 export function page(oops: Oops, shell: Shell = {}): string {
59 const ways = oops.ways ?? HOME;
60 return layout({
61 title: oops.title,
62 who: shell.who ?? ANON,
63 path: shell.path ?? "/",
64 cartCount: shell.cartCount ?? 0,
65 categories: categories(),
66 body: `<h1>${escape(oops.title)}</h1>
67 <p class="lede">${escape(oops.said)}</p>
68 <p>${ways.map((way, index) => button(way, index === 0)).join(" ")}</p>`,
69 });
70 }
71
72 /** The refusal as a response, for a caller that is known to be a browser. */
73 export const asPage = (oops: Oops, shell: Shell = {}): Response => html(page(oops, shell), oops.status);
74
75 /** The page for a browser, the line for everything else; the status is kept. */
76 export const respond = (request: Request, oops: Oops, shell: Shell = {}): Response =>
77 wantsHtml(request.headers) ? asPage(oops, shell) : text(oops.line ?? oops.said, oops.status);
src/server.test.ts +169
@@ -14,6 +14,7 @@ const { stamp } = await import("./who.ts");
1414 const { server } = await import("./server.ts");
1515 const { db } = await import("./db.ts");
1616 const { isRefused, readBody, readForm, refusal } = await import("./http.ts");
17 const { routes } = await import("./routes.ts");
1718 const limit = await import("./ratelimit.ts");
1819 const live = await import("./live.ts");
1920 const giftcards = await import("./giftcards.ts");
@@ -270,6 +271,174 @@ test("a post without the session's CSRF token is refused", async () => {
270271 expect(await shopper.page("/cart")).toContain("Nothing in it yet");
271272 });
272273
274 // --- a refusal is a page of the shop ----------------------------------------
275
276 /** What a browser navigating says it takes; a form POST carries it too. */
277 const BROWSER = { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" };
278
279 /** A page of this shop: its header, its stylesheet, and a way back out of it. */
280 function expectShopPage(response: Response, body: string): void {
281 expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
282 expect(response.headers.get("cache-control")).toBe("no-store");
283 // The floor is on a refusal like anything else.
284 expect(response.headers.get("x-content-type-options")).toBe("nosniff");
285 expect(body).toContain('<a class="brand" href="/">bazaar</a>');
286 expect(body).toContain('<link rel="stylesheet"');
287 expect(body).toContain('class="btn"');
288 expect(body).toContain("<h1>");
289 }
290
291 /** A POST at the front door, said the way a client of that kind says it. */
292 const knock = (path: string, init: RequestInit & { headers: Record<string, string> }): Promise<Response> =>
293 fetch(base + path, { method: "POST", redirect: "manual", ...init });
294
295 test("a cross-site post is a page of the shop for a browser and a line for anything else", async () => {
296 const cross = { origin: "https://evil.example", "sec-fetch-site": "cross-site" };
297 const page = await knock("/cart/add", {
298 body: new URLSearchParams({ variant: "1" }),
299 headers: { ...cross, ...BROWSER, "content-type": "application/x-www-form-urlencoded" },
300 });
301 expect(page.status).toBe(403);
302 const body = await page.text();
303 expectShopPage(page, body);
304 expect(body).toContain("sent from somewhere else");
305 expect(body).toContain("Back to the shop");
306
307 const machine = await knock("/cart/add", {
308 body: new URLSearchParams({ variant: "1" }),
309 headers: { ...cross, "content-type": "application/x-www-form-urlencoded" },
310 });
311 expect(machine.status).toBe(403);
312 expect(machine.headers.get("content-type")).toContain("text/plain");
313 expect(await machine.text()).toBe("forbidden");
314 });
315
316 test("a form that went stale is a page with the way back on it, and the session comes with it", async () => {
317 // A tab that sat open until its session rolled over: no cookie at all, so
318 // the refusal has to hand out the session the next attempt will sign with.
319 const stale = await knock("/cart/add", {
320 body: new URLSearchParams({ variant: String(variantId("COF-GUJI-250")), quantity: "1", csrf: "not-the-token" }),
321 headers: { "sec-fetch-site": "same-origin", ...BROWSER, "content-type": "application/x-www-form-urlencoded" },
322 });
323 expect(stale.status).toBe(403);
324 const body = await stale.text();
325 expectShopPage(stale, body);
326 expect(body).toContain("had gone stale");
327 expect(body).toContain('href="/cart"');
328 expect(body).toContain('href="/"');
329
330 const issued = stale.headers.getSetCookie().find((line) => line.startsWith("sid="));
331 expect(issued).toBeDefined();
332 expect(issued).toContain("HttpOnly");
333
334 // And going back and sending the form again works, rather than failing the
335 // same way twice: the cookie this refusal set is the one the page's token is
336 // from.
337 const shopper = new Shopper();
338 shopper.jar.set("sid", issued!.split(";")[0]!.split("=")[1]!);
339 const product = await shopper.page(`/p/${slugOf("COF-GUJI-250")}`);
340 const again = await shopper.post(
341 "/cart/add",
342 { variant: String(variantId("COF-GUJI-250")), quantity: "1" },
343 Shopper.tokenIn(product),
344 );
345 expect(again.status).toBe(303);
346 });
347
348 test("a body that is not a form, and a form that did not arrive whole, are pages too", async () => {
349 const notAForm = await knock("/cart/add", {
350 body: JSON.stringify({ variant: 1 }),
351 headers: { "sec-fetch-site": "same-origin", ...BROWSER, "content-type": "application/json" },
352 });
353 expect(notAForm.status).toBe(415);
354 expectShopPage(notAForm, await notAForm.text());
355
356 const halfAForm = await knock("/cart/add", {
357 body: "this was never a multipart body",
358 headers: { "sec-fetch-site": "same-origin", ...BROWSER, "content-type": "multipart/form-data; boundary=nope" },
359 });
360 expect(halfAForm.status).toBe(400);
361 expect(await halfAForm.text()).toContain("did not arrive whole");
362
363 // The lines a machine gets are the ones this app has always sent.
364 const line = await knock("/cart/add", {
365 body: JSON.stringify({ variant: 1 }),
366 headers: { "sec-fetch-site": "same-origin", "content-type": "application/json" },
367 });
368 expect(line.status).toBe(415);
369 expect(await line.text()).toBe("expected a form");
370 });
371
372 test("a body past the cap is a page for a browser, and stays a line for the webhook", async () => {
373 const big = "a".repeat(200 * 1024);
374 const page = await knock("/cart/add", {
375 body: new URLSearchParams({ variant: "1", filler: big }),
376 headers: { "sec-fetch-site": "same-origin", ...BROWSER, "content-type": "application/x-www-form-urlencoded" },
377 });
378 expect(page.status).toBe(413);
379 expectShopPage(page, await page.text());
380 expect(page.headers.get("connection")).toBe("close");
381
382 // Stripe's webhook is a machine's door however it asks: a raw route is never
383 // dressed up, even when the caller claims to take HTML.
384 const webhook = await knock("/webhooks/stripe", {
385 body: JSON.stringify({ type: "checkout.session.completed", padding: big }),
386 headers: { ...BROWSER, "content-type": "application/json", "stripe-signature": "t=1,v1=nope" },
387 });
388 expect(webhook.status).toBe(413);
389 expect(webhook.headers.get("content-type")).toContain("text/plain");
390 expect(await webhook.text()).toBe("that body was too large");
391 });
392
393 test("the 500 page says what happened to the log and nothing at all to the shopper", async () => {
394 // The only way to a 500 is a handler that throws, so one is lent one for the
395 // length of this test and given back in the finally below.
396 const route = routes.find((entry) => entry.method === "GET" && entry.pattern === "/search")!;
397 const real = route.handler;
398 const secret = "/srv/bazaar/src/shop.ts:412 — SQLITE_ERROR near \"SELECT\"";
399 const said: unknown[][] = [];
400 const spoke = console.error;
401 console.error = (...args: unknown[]) => void said.push(args);
402 route.handler = () => {
403 throw new Error(secret);
404 };
405 try {
406 const response = await fetch(base + "/search?q=espresso", { headers: BROWSER });
407 expect(response.status).toBe(500);
408 const body = await response.text();
409 expectShopPage(response, body);
410 expect(body).toContain("the shop failed on its own");
411 // Not the message, not the file, not the stack, not a word of any of it.
412 expect(body).not.toContain(secret);
413 expect(body).not.toContain("SQLITE");
414 expect(body).not.toContain("shop.ts");
415 expect(body).not.toContain("Error");
416 // The log keeps all of it.
417 expect(said.flat().some((part) => part instanceof Error && part.message === secret)).toBe(true);
418
419 const line = await fetch(base + "/search?q=espresso");
420 expect(line.status).toBe(500);
421 expect(await line.text()).toBe("something went wrong");
422 } finally {
423 route.handler = real;
424 console.error = spoke;
425 }
426 });
427
428 test("a limit a shopper meets is the same page, with its retry-after", async () => {
429 const shopper = new Shopper();
430 const form = Shopper.tokenIn(await shopper.page("/gift-cards"));
431 let last = new Response();
432 for (let attempt = 0; attempt < limit.BALANCE.capacity + 2; attempt++)
433 last = await shopper.post("/gift-cards", { code: "BZ-NOPE" }, form);
434 expect(last.status).toBe(429);
435 expect(Number(last.headers.get("retry-after"))).toBeGreaterThan(0);
436 const body = await last.text();
437 expectShopPage(last, body);
438 expect(body).toContain("Slow down");
439 limit.forget();
440 });
441
273442 // --- the cap on a body -------------------------------------------------------
274443
275444 /** A body that arrives in chunks and so carries no Content-Length at all. */
src/server.ts +90 −9
@@ -8,6 +8,10 @@
88 * every response, refuses a POST that is not same-origin and does not carry
99 * the session's CSRF token, works out who the forge says is asking, builds
1010 * the request's context, and hands over to the table in src/routes.ts.
11 *
12 * Every "no" it says is a page of the shop, rendered by src/pages/oops.ts —
13 * with the shop's header on it and a way back out — for a caller that asked
14 * for one, and the same line of plain text as ever for a caller that did not.
1115 */
1216
1317 import { dataDir, db, forgetPasswordSettings } from "./db.ts";
@@ -32,6 +36,8 @@ import { seed as seedDiscounts } from "./discounts.ts";
3236 import { seed as seedTax } from "./tax.ts";
3337 import { HAND_OFF, isRefused, readForm, readText, refusal, text, type Refused } from "./http.ts";
3438 import { returnTo, whoIs } from "./who.ts";
39 import { BACK_TO_CART, respond as oops, type Oops, type Shell } from "./pages/oops.ts";
40 import { cartCount } from "./context.ts";
3541 import type { Who } from "./who.ts";
3642 import type { Ctx } from "./context.ts";
3743
@@ -89,13 +95,77 @@ const sameOrigin = (request: Request): boolean => {
8995 return origin === null || origin === publicOrigin;
9096 };
9197
98 // Every refusal this door makes, as the sentence a shopper reads and the line
99 // a machine gets. The lines are the ones this app has always sent, because a
100 // caller that is not a browser has no use for manners; the pages are new. The
101 // 500's sentence is the whole of what a shopper is told: what went wrong goes
102 // to console.error and nowhere near a response.
103 const CROSS_SITE: Oops = {
104 status: 403,
105 title: "That came from another site",
106 said: "This shop only acts on its own forms, and that request was sent from somewhere else. Nothing was changed.",
107 line: "forbidden",
108 };
109
110 const STALE_FORM: Oops = {
111 status: 403,
112 title: "That form was not from here",
113 said:
114 "Forms here carry a token that says they came from this shop, and this form's token had gone stale — usually " +
115 "because the tab sat open a while. Your cart is untouched: open it again and send the form from there.",
116 ways: BACK_TO_CART,
117 line: "that form was not from here — go back and try again",
118 };
119
120 const NOT_A_FORM: Oops = {
121 status: 415,
122 title: "That was not a form",
123 said: "This address takes a form the shop served, and that request was something else.",
124 line: "expected a form",
125 };
126
127 const BAD_FORM: Oops = {
128 status: 400,
129 title: "That form did not arrive whole",
130 said: "The form came through in a shape the shop could not read. Go back and send it again.",
131 ways: BACK_TO_CART,
132 line: "bad form",
133 };
134
135 const BROKEN: Oops = {
136 status: 500,
137 title: "Something went wrong",
138 said: "That is ours, not yours: the shop failed on its own. Nothing was charged. Try it again in a moment.",
139 line: "something went wrong",
140 };
141
142 const TOO_MUCH: Oops = {
143 status: 413,
144 title: "That was more than the shop takes",
145 said: "The shop stopped reading that request: it was larger than anything a form here needs to be.",
146 line: refusal(413),
147 };
148
149 const TOO_SLOW: Oops = {
150 status: 408,
151 title: "That took too long to arrive",
152 said: "The shop waited for the rest of that request and stopped. Go back and send it again.",
153 ways: BACK_TO_CART,
154 line: refusal(408),
155 };
156
157 const refused = (status: Refused): Oops => (status === 413 ? TOO_MUCH : TOO_SLOW);
158
92159 /**
93160 * The answer to a body we stopped reading. It says close, and means it: bytes
94161 * of that body are still on the wire, and the next request on this connection
95162 * would read them as its own. A refusal ends the connection with the request.
163 *
164 * `dressed` is false for the routes a machine calls — Stripe's webhook reads
165 * its own body — where a page would be noise in a log.
96166 */
97 function refuse(status: Refused): Response {
98 const response = text(refusal(status), status);
167 function refuse(request: Request, status: Refused, shell: Shell, dressed = true): Response {
168 const response = dressed ? oops(request, refused(status), shell) : text(refusal(status), status);
99169 response.headers.set("connection", "close");
100170 return response;
101171 }
@@ -146,7 +216,9 @@ export const server = Bun.serve({
146216 [],
147217 );
148218
149 if (method === "POST" && !sameOrigin(request)) return finish(text("forbidden", 403), []);
219 // Nothing of this request is decoded to answer it — no session issued, no
220 // cart opened — so the page it gets is the shop's front and a way into it.
221 if (method === "POST" && !sameOrigin(request)) return finish(oops(request, CROSS_SITE), []);
150222
151223 const found = match(method, url.pathname);
152224 // Where a sign-in on this page comes back to, decided once and for every
@@ -172,27 +244,35 @@ export const server = Bun.serve({
172244 // wall-clock budget so a trickling client cannot keep a handler open —
173245 // including the one route that reads its own body, Stripe's webhook, which
174246 // gets it as ctx.body. A refusal comes back as the status to send.
247 //
248 // 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.
252 // 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)) });
175255 let form: FormData | null = null;
176256 let body: string | null = null;
177257 if (method === "POST" && found.route.raw) {
178258 const read = await readText(request);
179 if (isRefused(read)) return finish(refuse(read), outgoing);
259 if (isRefused(read)) return finish(refuse(request, read, shell(), false), outgoing);
180260 body = read;
181261 }
182262 if (method === "POST" && !found.route.raw) {
183263 const type = request.headers.get("content-type") ?? "";
184264 if (!type.startsWith("application/x-www-form-urlencoded") && !type.startsWith("multipart/form-data"))
185 return finish(text("expected a form", 415), outgoing);
265 return finish(oops(request, NOT_A_FORM, shell()), outgoing);
186266 let read: FormData | Refused;
187267 try {
188268 read = await readForm(request);
189269 } catch {
190 return finish(text("bad form", 400), outgoing);
270 return finish(oops(request, BAD_FORM, shell()), outgoing);
191271 }
192 if (isRefused(read)) return finish(refuse(read), outgoing);
272 if (isRefused(read)) return finish(refuse(request, read, shell()), outgoing);
193273 form = read;
194274 if (found.route.csrf !== false && !checkCsrf(session, form.get("csrf")))
195 return finish(text("that form was not from here — go back and try again", 403), outgoing);
275 return finish(oops(request, STALE_FORM, shell()), outgoing);
196276 }
197277
198278 const ctx = context(request, url, found.params, session, who, backTo, form, body, cookies, outgoing, self);
@@ -200,8 +280,9 @@ export const server = Bun.serve({
200280 const response = await found.route.handler(ctx);
201281 return finish(request.method === "HEAD" ? new Response(null, response) : response, outgoing);
202282 } catch (error) {
283 // The log keeps the exception whole; the shopper is told a sentence.
203284 console.error(`${method} ${url.pathname}:`, error);
204 return finish(text("something went wrong", 500), outgoing);
285 return finish(oops(request, BROKEN, { who, path: backTo, cartCount: cartCount(ctx.cart) }), outgoing);
205286 }
206287 },
207288 });
src/shop.ts +14 −11
@@ -19,6 +19,7 @@ import * as limit from "./ratelimit.ts";
1919 import * as mine from "./mine.ts";
2020 import * as orders from "./orders.ts";
2121 import * as giftCardPages from "./pages/giftcard.ts";
22 import * as oops from "./pages/oops.ts";
2223 import * as pages from "./pages/checkout.ts";
2324 import * as storefront from "./pages/storefront.ts";
2425 import * as tax from "./tax.ts";
@@ -35,14 +36,13 @@ import {
3536 } from "./catalog.ts";
3637 import { field, handOff, intField, looksLikeEmail, redirect, text } from "./http.ts";
3738 import { page as ordersPageView } from "./pages/orders.ts";
38 import { render, type Ctx } from "./context.ts";
39 import { cartCount, render, type Ctx } from "./context.ts";
3940 import { page as cartPage } from "./pages/cart.ts";
4041 import { chosen } from "./gateway.ts";
4142 import { equals, hmac, sign, unsign } from "./sign.ts";
4243 import { forOrder } from "./mail.ts";
4344 import { linkTo, opensByAccount } from "./orderlink.ts";
4445 import { dueCents, parseQuantity } from "./money.ts";
45 import { escape } from "./html.ts";
4646 import type { Order } from "./db.ts";
4747
4848 const publicOrigin = (): string => new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
@@ -593,14 +593,17 @@ export function stripeWebhook(ctx: Ctx): Response {
593593 // --- limits -----------------------------------------------------------------
594594
595595 export function tooMany(ctx: Ctx, seconds: number, what: string): Response {
596 const body = render(
597 ctx,
598 { title: "Slow down", status: 429 },
599 `<h1>Slow down</h1><p class="lede">Too many ${escape(what)} attempts from here. Try again in ${seconds} second${
600 seconds === 1 ? "" : "s"
601 }.</p>`,
596 // The same page as every other refusal, from src/pages/oops.ts: a limit a
597 // shopper meets is no plainer an answer than a form that went stale. It is
598 // always the page — whoever trips a limit here got here from a page.
599 const response = oops.asPage(
600 {
601 status: 429,
602 title: "Slow down",
603 said: `Too many ${what} attempts from here. Try again in ${seconds} second${seconds === 1 ? "" : "s"}.`,
604 },
605 { who: ctx.who, path: ctx.backTo, cartCount: cartCount(ctx.cart) },
602606 );
603 const headers = new Headers(body.headers);
604 headers.set("retry-after", String(Math.max(1, seconds)));
605 return new Response(body.body, { status: 429, headers });
607 response.headers.set("retry-after", String(Math.max(1, seconds)));
608 return response;
606609 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs