Public: anyone can read this; the crew can change it.
A first-party beacon reports each page's real load, LCP, INP, CLS and TTFB to the forge, keeping no IP #2733
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2711-a-first-party-beacon-reports-each-page-s → main 4 commits fork 6 threads · 6 unresolved
README.md +4
| @@ -228,6 +228,10 @@ form that mints one and shows its secret once; others see no section. | ||
| 228 | 228 | `owner/name`. `n` mints (the child's name, then its https address) and shows |
| 229 | 229 | the secret once on its own screen; `x` revokes the one under the cursor. |
| 230 | 230 | |
| 231 | ## Page-view beacon | |
| 232 | ||
| 233 | Every page links one static, immutable script (`src/web/beacon.ts`, under 2 KB, no dependency) that sends, once per page view with `sendBeacon`, the route's path, TTFB, load time, LCP, INP (the worst interaction) and CLS to `POST /_measure`. The route is same-origin only, validates every field, caps the body at 512 bytes and the rate per client, and answers 204. Reports land in `page_vital` (bun:sqlite) keyed by route *pattern*; no IP and no user agent string is stored. A unique visitor is a hash under a salt that rotates daily (yesterday's salt is deleted), rows are kept 90 days. Do-Not-Track and the `forge_measure=off` cookie send nothing, and the server stores nothing for them either. The CSP is unchanged: the script is nonce'd like the shell's. | |
| 234 | ||
| 231 | 235 | ## Live in the terminal |
| 232 | 236 | |
| 233 | 237 | `ssh` into the forge and you get the whole thing — repositories, code, issues, |
src/core/migration-pins.json +2 −1
| @@ -119,5 +119,6 @@ | ||
| 119 | 119 | "20261018110000-limb-replan": "23d6a14e5ef934c4d8f1509377ac8a15dc6a0097313127558b4c5a655a24581e", |
| 120 | 120 | "20261019120000-drill-phases": "574071a8576f982fe8483af90b472a03793ffa03e3b391c786d5c5144e3bb82e", |
| 121 | 121 | "20261019130000-run-model-prompt": "ce2e94f439649fc042c0a951f1af4a740b91f674dc94521e865ec517f68994f5", |
| 122 | "20261019140000-route-timing": "0fd7cfa4e0a50c4b2b36162601f9ddc3318e7fa445559732364b2b52a06de011" | |
| 122 | "20261019140000-route-timing": "0fd7cfa4e0a50c4b2b36162601f9ddc3318e7fa445559732364b2b52a06de011", | |
| 123 | "20261019150000-page-vitals": "63fe647d95733a5194a99ae955181f2456d5c2e8c9c38d37bbf1bad395b72bdf" | |
| 123 | 124 | } |
src/core/migrations/20261019150000-page-vitals.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | // A first-party beacon reports each page view's real load, LCP, INP, CLS and | |
| 2 | // TTFB (#2711). No IP and no user agent string is kept: `visitor` is a hash | |
| 3 | // under a salt that rotates daily, and yesterday's salt is deleted, so a | |
| 4 | // visitor cannot be followed from one day to the next. A day holds at most a | |
| 5 | // fixed number of rows (`DAY_MAX`), so strangers cannot grow the table. A metric the browser | |
| 6 | // could not measure is NULL. | |
| 7 | export default ` | |
| 8 | CREATE TABLE page_vital ( | |
| 9 | id INTEGER PRIMARY KEY, | |
| 10 | day TEXT NOT NULL, | |
| 11 | at INTEGER NOT NULL, | |
| 12 | route TEXT NOT NULL, | |
| 13 | visitor TEXT NOT NULL, | |
| 14 | ttfb REAL, | |
| 15 | load REAL, | |
| 16 | lcp REAL, | |
| 17 | inp REAL, | |
| 18 | cls REAL NOT NULL | |
| 19 | ); | |
| 20 | CREATE INDEX page_vital_day ON page_vital (day); | |
| 21 | CREATE TABLE vital_salt ( | |
| 22 | day TEXT PRIMARY KEY, | |
| 23 | salt TEXT NOT NULL | |
| 24 | ); | |
| 25 | `; |
src/http/measure.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | /** | |
| 2 | * POST /_measure: the page-view beacon's door. Same-origin only (a beacon | |
| 3 | * from a page always carries Origin; anything else is refused — that is CSRF | |
| 4 | * protection only, any script can set the header), opted-out | |
| 5 | * visitors stored nothing, a rate per client and per forge, a body of a few | |
| 6 | * hundred bytes at most, every field validated (`web/measure.ts`), and the | |
| 7 | * route is one the forge serves. What protects the database is the bound on | |
| 8 | * what is stored (`DAY_MAX` rows a day, `recordVital`), not the checks here. The answer is always an empty 204 or a | |
| 9 | * short refusal; nothing is read back. | |
| 10 | */ | |
| 11 | ||
| 12 | import { RateLimiter } from "../core/ratelimit.ts"; | |
| 13 | import type { App } from "../app.ts"; | |
| 14 | import { optedOut, parseReport, recordVital, REPORT_MAX } from "../web/measure.ts"; | |
| 15 | import type { Ctx } from "./context.ts"; | |
| 16 | import { readBounded } from "./limits.ts"; | |
| 17 | import { clientAddress, clientBucket } from "./peer.ts"; | |
| 18 | ||
| 19 | const PER_CLIENT = 30; | |
| 20 | const PER_FORGE = 1200; | |
| 21 | ||
| 22 | const limiter = (perMinute: number) => | |
| 23 | new RateLimiter({ perMinute, adminPerMinute: perMinute, enabled: true }); | |
| 24 | ||
| 25 | /** One pair per forge, so a test's forge never throttles another's. */ | |
| 26 | const limits = new WeakMap<App, { client: RateLimiter; forge: RateLimiter }>(); | |
| 27 | ||
| 28 | const refuse = (status: number, headers?: Record<string, string>): Response => | |
| 29 | new Response(null, { status, headers: { "cache-control": "no-store", ...headers } }); | |
| 30 | ||
| 31 | /** The route pattern a path belongs to, or undefined when none does. */ | |
| 32 | export type RouteOf = (path: string) => string | undefined; | |
| 33 | ||
| 34 | export async function measureSubmit(ctx: Ctx, routeOf: RouteOf): Promise<Response> { | |
outdated
This is CSRF protection, not an abuse gate: any non-browser client can set Origin. The comment above should say so, and the storage bound in |
||
| 35 | const { request, app } = ctx; | |
| 36 | if (request.headers.get("origin") !== ctx.url.origin) return refuse(403); | |
| 37 | if (optedOut(request)) return refuse(204); | |
| 38 | ||
| 39 | let pair = limits.get(app); | |
| 40 | if (pair === undefined) { | |
| 41 | pair = { client: limiter(PER_CLIENT), forge: limiter(PER_FORGE) }; | |
| 42 | limits.set(app, pair); | |
| 43 | } | |
| 44 | for (const verdict of [ | |
| 45 | pair.client.take(clientBucket(request), "ro"), | |
| 46 | pair.forge.take("forge", "ro"), | |
| 47 | ]) { | |
| 48 | if (!verdict.allowed) | |
| 49 | return refuse(429, { "retry-after": String(Math.max(1, verdict.retryAfter)) }); | |
| 50 | } | |
| 51 | ||
| 52 | const bytes = await readBounded(request, REPORT_MAX); | |
| 53 | if (bytes === "too_large") return refuse(413); | |
| 54 | const report = parseReport(new TextDecoder().decode(bytes)); | |
| 55 | if (report === "malformed") return refuse(400); | |
| 56 | const route = routeOf(report.path); | |
| 57 | if (route === undefined) return refuse(400); | |
| 58 | ||
| 59 | recordVital( | |
| 60 | app.db, | |
| 61 | report, | |
| 62 | route, | |
| 63 | clientAddress(request), | |
| 64 | request.headers.get("user-agent") ?? "", | |
| 65 | ); | |
| 66 | return refuse(204); | |
| 67 | } |
src/http/server.ts +12
| @@ -22,6 +22,7 @@ import { flagsPage, flagsSet } from "../web/pages/flags.ts"; | ||
| 22 | 22 | import { usagePage } from "../web/pages/usage.ts"; |
| 23 | 23 | import { resiliencePage } from "../web/pages/resilience.ts"; |
| 24 | 24 | import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 25 | import { measureSubmit } from "./measure.ts"; | |
| 25 | 26 | import { boardsPage } from "../web/pages/boards.ts"; |
| 26 | 27 | import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts"; |
| 27 | 28 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| @@ -96,6 +97,7 @@ import { sha256 } from "../kernel/hash.ts"; | ||
| 96 | 97 | import { |
| 97 | 98 | assetResponse, |
| 98 | 99 | highlightAsset, |
| 100 | beaconAsset, | |
| 99 | 101 | shellAsset, |
| 100 | 102 | stylesheetAsset, |
| 101 | 103 | stylesheetRedirect, |
| @@ -640,6 +642,8 @@ export function buildRouter(): Router<Handler> { | ||
| 640 | 642 | router.add("GET", "/assets/:name.js", async (ctx) => { |
| 641 | 643 | const shell = shellAsset(); |
| 642 | 644 | if (ctx.params.name === shell.name) return assetResponse(ctx.request, shell); |
| 645 | if (ctx.params.name === beaconAsset().name) | |
| 646 | return assetResponse(ctx.request, beaconAsset()); | |
| 643 | 647 | if (ctx.params.name === TOOLBAR_NAME) return toolbarResponse(ctx.request); |
| 644 | 648 | const asset = await highlightAsset(); |
| 645 | 649 | if (asset === null || ctx.params.name !== asset.name) return notFound(); |
| @@ -648,6 +652,8 @@ export function buildRouter(): Router<Handler> { | ||
| 648 | 652 | // The service worker: root scope needs a root path, and it must never be |
| 649 | 653 | // cached by the browser's HTTP cache or an update would wait a day. |
| 650 | 654 | router.add("GET", "/sw.js", serviceWorker); |
| 655 | // The page-view beacon's report (http/measure.ts): numbers only, no IP kept. | |
| 656 | router.add("POST", "/_measure", (ctx) => measureSubmit(ctx, routeOf)); | |
| 651 | 657 | router.add("GET", "/", home); |
| 652 | 658 | // A spawned child's claim link: GET shows, POST takes it (spawn/claim.ts). |
| 653 | 659 | router.add("GET", "/claim", claimFragmentPage); |
| @@ -1379,6 +1385,12 @@ export function buildApiRouter(): Router<Handler> { | ||
| 1379 | 1385 | return router; |
| 1380 | 1386 | } |
| 1381 | 1387 | |
| 1388 | let pages: Router<Handler> | undefined; | |
| 1389 | /** The route pattern a page's path belongs to, for the beacon: never the path itself. */ | |
| 1390 | function routeOf(path: string): string | undefined { | |
| 1391 | return (pages ??= buildRouter()).match("GET", path)?.pattern; | |
| 1392 | } | |
| 1393 | ||
| 1382 | 1394 | let known: string[] | undefined; |
| 1383 | 1395 | /** Every pattern the studio's and the API's routers know, read once on first use. */ |
| 1384 | 1396 | export function knownPatterns(): string[] { |
src/web/asset.ts +13
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | |
| 19 | 19 | import { sha256 } from "../kernel/hash.ts"; |
| 20 | 20 | import { createLog } from "../kernel/log.ts"; |
| 21 | import { beaconJs } from "./beacon.ts"; | |
| 21 | 22 | import { shellJs } from "./client.ts"; |
| 22 | 23 | import { css } from "./style.ts"; |
| 23 | 24 | |
| @@ -148,3 +149,15 @@ export function shellAsset(): BuiltAsset { | ||
| 148 | 149 | } |
| 149 | 150 | return shell; |
| 150 | 151 | } |
| 152 | ||
| 153 | let beacon: BuiltAsset | null = null; | |
| 154 | ||
| 155 | /** The page-view beacon (`web/beacon.ts`), content-addressed and immutable. */ | |
| 156 | export function beaconAsset(): BuiltAsset { | |
| 157 | if (beacon === null) { | |
| 158 | const hash = sha256(beaconJs); | |
| 159 | const name = `beacon-${hash.slice(0, 16)}`; | |
| 160 | beacon = { path: `/assets/${name}.js`, name, code: beaconJs, etag: `"${hash}"` }; | |
| 161 | } | |
| 162 | return beacon; | |
| 163 | } |
src/web/beacon.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | /** | |
| 2 | * The page-view beacon, as static source: one classic script, under 2 KB, | |
| 3 | * served content-addressed (`web/asset.ts`) and linked from every page. It | |
| 4 | * watches LCP, layout shift (CLS) and interaction latency (INP, the worst | |
| 5 | * interaction), and sends them once, with the route's path and the | |
| 6 | * navigation's TTFB and load time, when the page is hidden or left. The path | |
| 7 | * is read once, at start: the code browser moves with pushState, and the load | |
| 8 | * being measured belongs to the page that was loaded. Nothing | |
| 9 | * is sent under Do-Not-Track or with the `forge_measure=off` cookie; the | |
| 10 | * server checks both again. A metric it could not measure is 0 (or absent). | |
| 11 | */ | |
| 12 | export const beaconJs = `!function(n,d,w){ | |
| 13 | if(n.doNotTrack=="1"||/(^|; )forge_measure=off/.test(d.cookie)||!n.sendBeacon||!w.PerformanceObserver)return; | |
| 14 | var c=0,i=0,l=0,x=0,a=location.pathname; | |
| 15 | function o(t,f,e){try{new PerformanceObserver(function(s){s.getEntries().forEach(f)}).observe(Object.assign({type:t,buffered:true},e))}catch(_){}} | |
| 16 | o("largest-contentful-paint",function(e){l=e.startTime}); | |
| 17 | o("layout-shift",function(e){e.hadRecentInput||(c+=e.value)}); | |
| 18 | o("event",function(e){e.interactionId&&e.duration>i&&(i=e.duration)},{durationThreshold:40}); | |
outdated
|
||
| 19 | function s(){if(x)return;x=1;var v=performance.getEntriesByType("navigation")[0]||{}; | |
| 20 | n.sendBeacon("/_measure",JSON.stringify({path:a,ttfb:v.responseStart||0,load:v.loadEventEnd||0,lcp:l,inp:i,cls:c}))} | |
| 21 | d.addEventListener("visibilitychange",function(){d.visibilityState=="hidden"&&s()}); | |
| 22 | w.addEventListener("pagehide",s) | |
| 23 | }(navigator,document,window)`; |
src/web/layout.ts +2 −1
| @@ -25,7 +25,7 @@ import { | ||
| 25 | 25 | THEMES, |
| 26 | 26 | type ThemeName, |
| 27 | 27 | } from "../design/themes.ts"; |
| 28 | import { shellAsset, stylesheetAsset } from "./asset.ts"; | |
| 28 | import { beaconAsset, shellAsset, stylesheetAsset } from "./asset.ts"; | |
| 29 | 29 | import { linkHeader, pageLinks } from "./standards.ts"; |
| 30 | 30 | import { |
| 31 | 31 | clearFlashCookie, |
| @@ -672,6 +672,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 672 | 672 | <body> |
| 673 | 673 | ${shell}${iconSprite(shell.text)} |
| 674 | 674 | <script nonce="${nonce}" src="${shellAsset().path}"></script> |
| 675 | <script nonce="${nonce}" src="${beaconAsset().path}" defer></script> | |
| 675 | 676 | ${options.scriptJs |
| 676 | 677 | ? raw(`<script nonce="${nonce}">${options.scriptJs}</script>`) |
| 677 | 678 | : null} |
src/web/measure.ts added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * The page-view beacon's receiver: validate a report, hash the visitor, store | |
| 3 | * the numbers. Plain data in, plain data out. What is stored is the route | |
| 4 | * pattern (never the path, which can name a repository), the five metrics, | |
| 5 | * and a visitor hash; the address and the user agent string are read to make | |
| 6 | * that hash and are never written anywhere. | |
| 7 | * | |
| 8 | * The salt rotates daily and the old salts are deleted, so a hash means "the | |
| 9 | * same browser today" and cannot be joined across days. Rows are kept 90 days. | |
| 10 | * | |
| 11 | * Anyone can post here, so the table is bounded by what is stored, not only | |
| 12 | * by the rate: a day keeps at most DAY_MAX rows and the rest are dropped. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { Database } from "bun:sqlite"; | |
| 16 | import { sha256 } from "../kernel/hash.ts"; | |
| 17 | ||
| 18 | /** A report is a few dozen bytes; this is already generous. */ | |
| 19 | export const REPORT_MAX = 512; | |
| 20 | /** Times in milliseconds: ten minutes is longer than any real page view. */ | |
| 21 | export const TIME_MAX = 600_000; | |
| 22 | export const CLS_MAX = 100; | |
| 23 | /** The most rows one day keeps; past it a report is dropped (and still answered 204). */ | |
| 24 | export const DAY_MAX = 20_000; | |
| 25 | const KEEP_MS = 90 * 24 * 60 * 60 * 1000; | |
| 26 | ||
| 27 | export interface Report { | |
| 28 | readonly path: string; | |
| 29 | readonly ttfb: number | null; | |
| 30 | readonly load: number | null; | |
| 31 | readonly lcp: number | null; | |
| 32 | readonly inp: number | null; | |
| 33 | readonly cls: number; | |
| 34 | } | |
| 35 | ||
| 36 | const FIELDS = new Set(["path", "ttfb", "load", "lcp", "inp", "cls"]); | |
| 37 | ||
| 38 | const bounded = (value: unknown, max: number): number | undefined => | |
| 39 | typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= max | |
| 40 | ? value | |
| 41 | : undefined; | |
| 42 | ||
| 43 | /** A report from the body's text, or why not. Unknown keys are refused. */ | |
| 44 | export function parseReport(text: string): Report | "malformed" { | |
| 45 | let raw: unknown; | |
| 46 | try { | |
| 47 | raw = JSON.parse(text); | |
| 48 | } catch { | |
| 49 | return "malformed"; | |
| 50 | } | |
| 51 | if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return "malformed"; | |
| 52 | const body = raw as Record<string, unknown>; | |
| 53 | if (Object.keys(body).some((key) => !FIELDS.has(key))) return "malformed"; | |
| 54 | const { path } = body; | |
| 55 | if (typeof path !== "string" || path.length > 512 || !path.startsWith("/")) | |
| 56 | return "malformed"; | |
| 57 | // eslint-disable-next-line no-control-regex | |
| 58 | if (/[\u0000-\u001f\u007f]/.test(path)) return "malformed"; | |
| 59 | const cls = bounded(body["cls"], CLS_MAX); | |
| 60 | if (cls === undefined) return "malformed"; | |
| 61 | const times: Record<string, number | null> = {}; | |
| 62 | for (const key of ["ttfb", "load", "lcp", "inp"] as const) { | |
| 63 | const given = body[key]; | |
| 64 | if (given === undefined) { | |
| 65 | times[key] = null; | |
| 66 | continue; | |
| 67 | } | |
| 68 | const value = bounded(given, TIME_MAX); | |
| 69 | if (value === undefined) return "malformed"; | |
| 70 | // 0 is how the beacon says "not measured". | |
| 71 | times[key] = value === 0 ? null : value; | |
| 72 | } | |
| 73 | return { | |
| 74 | path, | |
| 75 | ttfb: times["ttfb"] ?? null, | |
| 76 | load: times["load"] ?? null, | |
| 77 | lcp: times["lcp"] ?? null, | |
| 78 | inp: times["inp"] ?? null, | |
| 79 | cls, | |
| 80 | }; | |
| 81 | } | |
| 82 | ||
| 83 | /** The visitor asked not to be measured: Do-Not-Track, or the opt-out cookie. */ | |
| 84 | export function optedOut(request: Request): boolean { | |
| 85 | if (request.headers.get("dnt") === "1") return true; | |
| 86 | const cookie = request.headers.get("cookie") ?? ""; | |
| 87 | return cookie.split(";").some((pair) => pair.trim() === "forge_measure=off"); | |
| 88 | } | |
| 89 | ||
| 90 | const dayOf = (at: number): string => new Date(at).toISOString().slice(0, 10); | |
| 91 | ||
| 92 | /** Today's salt, made on first use; every other day's is deleted. */ | |
| 93 | function saltFor(db: Database, at: number): string { | |
| 94 | const day = dayOf(at); | |
| 95 | const found = db | |
| 96 | .query<{ salt: string }, [string]>("SELECT salt FROM vital_salt WHERE day = ?") | |
| 97 | .get(day); | |
| 98 | if (found !== null) return found.salt; | |
| 99 | const salt = new Bun.CryptoHasher("sha256") | |
outdated
Low. The 90-day prune runs only when the salt rotates, which is the first report of a UTC day. That works, but it hides the retention policy inside |
||
| 100 | .update(crypto.getRandomValues(new Uint8Array(32))) | |
| 101 | .digest("hex"); | |
| 102 | db.run("DELETE FROM vital_salt WHERE day <> ?", [day]); | |
| 103 | db.run("INSERT OR IGNORE INTO vital_salt (day, salt) VALUES (?, ?)", [day, salt]); | |
| 104 | db.run("DELETE FROM page_vital WHERE at < ?", [at - KEEP_MS]); | |
| 105 | return ( | |
| 106 | db | |
| 107 | .query<{ salt: string }, [string]>("SELECT salt FROM vital_salt WHERE day = ?") | |
| 108 | .get(day)?.salt ?? salt | |
| 109 | ); | |
| 110 | } | |
| 111 | ||
| 112 | /** | |
| 113 | * Store one report; false when the day is full and it was dropped. `client` | |
| 114 | * and `agent` go into the hash and nowhere else. | |
| 115 | */ | |
| 116 | export function recordVital( | |
| 117 | db: Database, | |
| 118 | report: Report, | |
outdated
High: one unauthenticated row per report, about 1.7M/day at the forge cap, kept 90 days. Bound it here: aggregate into per-(day, route) histograms, or skip the insert once today's row count reaches a cap, still answering 204. |
||
| 119 | route: string, | |
| 120 | client: string, | |
| 121 | agent: string, | |
| 122 | at: number = Date.now(), | |
| 123 | dayMax: number = DAY_MAX, | |
| 124 | ): boolean { | |
| 125 | const held = db | |
| 126 | .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM page_vital WHERE day = ?") | |
| 127 | .get(dayOf(at)); | |
| 128 | if ((held?.n ?? 0) >= dayMax) return false; | |
| 129 | const visitor = sha256(`${saltFor(db, at)}\0${client}\0${agent}`).slice(0, 32); | |
| 130 | db.run( | |
| 131 | "INSERT INTO page_vital (day, at, route, visitor, ttfb, load, lcp, inp, cls) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 132 | [dayOf(at), at, route, visitor, report.ttfb, report.load, report.lcp, report.inp, report.cls], | |
| 133 | ); | |
| 134 | return true; | |
| 135 | } |
test/measure.test.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 7 | import { beaconJs } from "../src/web/beacon.ts"; | |
| 8 | import { beaconAsset } from "../src/web/asset.ts"; | |
| 9 | import { DAY_MAX, parseReport, recordVital } from "../src/web/measure.ts"; | |
| 10 | ||
| 11 | let dir: string; | |
| 12 | let app: App; | |
| 13 | let servers: Servers; | |
| 14 | let base: string; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dir = await mkdtemp(join(tmpdir(), "measure-")); | |
| 18 | app = createApp({ dataDir: dir, port: 0 }); | |
| 19 | servers = await serve(app); | |
| 20 | base = `http://127.0.0.1:${servers.port}`; | |
| 21 | }); | |
| 22 | afterAll(async () => { | |
| 23 | await servers.stop(); | |
| 24 | await rm(dir, { recursive: true, force: true }); | |
| 25 | }); | |
| 26 | ||
| 27 | const good = { path: "/", ttfb: 40, load: 300, lcp: 250, inp: 80, cls: 0.02 }; | |
| 28 | const send = (body: unknown, headers: Record<string, string> = {}) => | |
| 29 | fetch(`${base}/_measure`, { | |
| 30 | method: "POST", | |
| 31 | headers: { origin: base, ...headers }, | |
| 32 | body: typeof body === "string" ? body : JSON.stringify(body), | |
| 33 | }); | |
| 34 | const rows = () => app.db.query("SELECT * FROM page_vital").all() as Record<string, unknown>[]; | |
| 35 | const count = () => rows().length; | |
| 36 | ||
| 37 | describe("the page-view beacon", () => { | |
| 38 | test("the script is under 2048 bytes, immutable, and linked from a page", async () => { | |
| 39 | expect(new TextEncoder().encode(beaconJs).length).toBeLessThan(2048); | |
| 40 | const asset = beaconAsset(); | |
| 41 | const script = await fetch(base + asset.path); | |
| 42 | expect(script.status).toBe(200); | |
| 43 | expect(script.headers.get("cache-control")).toContain("immutable"); | |
| 44 | expect(await script.text()).toBe(beaconJs); | |
| 45 | const home = await fetch(base + "/"); | |
| 46 | expect(await home.text()).toContain(asset.path); | |
| 47 | expect(home.headers.get("content-security-policy")).toContain("script-src 'nonce-"); | |
| 48 | expect(home.headers.get("content-security-policy")).not.toContain("unsafe"); | |
| 49 | }); | |
| 50 | ||
| 51 | test("the path is read once, at start, not when the page hides", () => { | |
| 52 | expect(beaconJs.match(/location\.pathname/g)).toHaveLength(1); | |
| 53 | expect(beaconJs).toContain("a=location.pathname"); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a valid report is stored by route pattern, with no address or agent", async () => { | |
| 57 | const before = count(); | |
| 58 | const res = await send({ ...good, path: "/someone/thing" }, { "user-agent": "Mozilla/5.0 Secret" }); | |
| 59 | expect(res.status).toBe(204); | |
| 60 | expect(count()).toBe(before + 1); | |
| 61 | const row = rows().at(-1)!; | |
| 62 | expect(row["route"]).toBe("/:owner/:name"); | |
| 63 | expect(row["lcp"]).toBe(250); | |
| 64 | expect(row["cls"]).toBe(0.02); | |
| 65 | expect(String(row["visitor"])).toMatch(/^[0-9a-f]{32}$/); | |
| 66 | const text = JSON.stringify(rows()); | |
| 67 | expect(text).not.toContain("Secret"); | |
| 68 | expect(text).not.toContain("127.0.0.1"); | |
| 69 | expect(text).not.toContain("someone"); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a cross-origin, originless, oversized or malformed report is refused", async () => { | |
| 73 | const before = count(); | |
| 74 | expect((await send(good, { origin: "https://evil.example" })).status).toBe(403); | |
| 75 | const bare = await fetch(`${base}/_measure`, { method: "POST", body: JSON.stringify(good) }); | |
| 76 | expect(bare.status).toBe(403); | |
| 77 | expect((await send("x".repeat(5000))).status).toBe(413); | |
| 78 | expect((await send("{nope")).status).toBe(400); | |
| 79 | expect((await send({ ...good, cls: "1" })).status).toBe(400); | |
| 80 | expect((await send({ ...good, lcp: 1e12 })).status).toBe(400); | |
| 81 | expect((await send({ ...good, lcp: -1 })).status).toBe(400); | |
| 82 | expect((await send({ ...good, extra: 1 })).status).toBe(400); | |
| 83 | expect((await send({ ...good, path: "https://evil.example/" })).status).toBe(400); | |
| 84 | expect((await send({ ...good, path: "/a\nb" })).status).toBe(400); | |
| 85 | expect((await send([1])).status).toBe(400); | |
| 86 | expect((await send({ ...good, path: "/_nothing/at/all/here/x/y/z" })).status).toBe(400); | |
| 87 | expect(count()).toBe(before); | |
| 88 | }); | |
| 89 | ||
| 90 | test("Do-Not-Track and the opt-out cookie store nothing", async () => { | |
| 91 | const before = count(); | |
| 92 | expect((await send(good, { dnt: "1" })).status).toBe(204); | |
| 93 | expect((await send(good, { cookie: "a=b; forge_measure=off" })).status).toBe(204); | |
| 94 | expect(count()).toBe(before); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a client past its rate is told to wait", async () => { | |
| 98 | const statuses: number[] = []; | |
| 99 | for (let i = 0; i < 40; i++) statuses.push((await send(good)).status); | |
| 100 | expect(statuses).toContain(429); | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | describe("the report and the visitor hash", () => { | |
| 105 | test("0 means not measured, and absent times are null", () => { | |
| 106 | expect(parseReport('{"path":"/","cls":0,"lcp":0}')).toEqual({ | |
| 107 | path: "/", ttfb: null, load: null, lcp: null, inp: null, cls: 0, | |
| 108 | }); | |
| 109 | }); | |
| 110 | ||
| 111 | test("the salt rotates daily: same browser, same day, same hash; next day, another", () => { | |
| 112 | const report = parseReport(JSON.stringify(good)); | |
| 113 | if (report === "malformed") throw new Error("bad fixture"); | |
| 114 | const day = Date.UTC(2026, 0, 5, 10); | |
| 115 | const visitor = () => (rows().at(-1) as { visitor: string }).visitor; | |
| 116 | app.db.run("DELETE FROM page_vital"); | |
| 117 | recordVital(app.db, report, "/", "1.2.3.4", "ua", day); | |
| 118 | const first = visitor(); | |
| 119 | recordVital(app.db, report, "/", "1.2.3.4", "ua", day + 1000); | |
| 120 | expect(visitor()).toBe(first); | |
| 121 | recordVital(app.db, report, "/", "1.2.3.4", "ua", day + 86_400_000); | |
| 122 | expect(visitor()).not.toBe(first); | |
| 123 | expect((app.db.query("SELECT COUNT(*) AS n FROM vital_salt").get() as { n: number }).n).toBe(1); | |
| 124 | }); | |
| 125 | ||
| 126 | test("a full day drops reports instead of growing the table", () => { | |
| 127 | const report = parseReport(JSON.stringify(good)); | |
| 128 | if (report === "malformed") throw new Error("bad fixture"); | |
| 129 | const at = Date.UTC(2026, 5, 1, 12); | |
| 130 | app.db.run("DELETE FROM page_vital"); | |
| 131 | for (let i = 0; i < 5; i++) expect(recordVital(app.db, report, "/", "c", "u", at, 5)).toBe(true); | |
| 132 | expect(recordVital(app.db, report, "/", "c", "u", at, 5)).toBe(false); | |
| 133 | expect(count()).toBe(5); | |
| 134 | // The next day has its own room. | |
| 135 | expect(recordVital(app.db, report, "/", "c", "u", at + 86_400_000, 5)).toBe(true); | |
| 136 | expect(DAY_MAX).toBeLessThanOrEqual(50_000); | |
| 137 | }); | |
| 138 | }); |
test/web-browse.test.ts +1 −1
| @@ -502,7 +502,7 @@ describe("browsing an origin repository on the web", () => { | ||
| 502 | 502 | ); |
| 503 | 503 | expect(running).toContain("new EventSource(url)"); |
| 504 | 504 | expect(running).toMatch(/<script nonce="[^"]+">/); |
| 505 | expect(running).not.toMatch(/<script[^>]*src="(?!\/assets\/shell-)/); | |
| 505 | expect(running).not.toMatch(/<script[^>]*src="(?!\/assets\/(?:shell|beacon)-)/); | |
| 506 | 506 | |
| 507 | 507 | const done = await settled(jobPath.split("/").pop()!); |
| 508 | 508 | expect(done?.stage).toBe("done"); |
Medium (DoS/storage). Anyone who sets an Origin header (curl can) may write 1200 rows a minute from many addresses. That is about 1.7M rows a day, kept for 90 days, so on the order of 10^8 rows on the owner's disk. One address alone writes about 43k rows a day. Fix: lower the forge cap to what one person's browsing produces (around 120/min). Better, bound the table too: skip the insert once a day's row count passes a ceiling, e.g.
SELECT COUNT(*) FROM page_vital WHERE day = ?against 50k. That way a flood cannot outgrow the disk.