Public: anyone can read this; the crew can change it.
The traffic view knows a returning visitor: new versus returning, how many vi… #11267
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11265-the-traffic-view-knows-a-returning-visit → main 9 commits fork
31 files
changed +1140
−4
docs/design/audit.md +1
| @@ -93,6 +93,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 93 | 93 | - `/forges/new` — `pages/new-forge.ts` · send a new forge · none · colour 1. |
| 94 | 94 | - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1. |
| 95 | 95 | - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1. |
| 96 | - `/privacy` — `pages/privacy.ts` · what the forge keeps about a visitor, three plain lines, open to anyone · none · none seen. | |
| 96 | 97 | - `/first-admin` — `pages/first-admin.ts` · the first person becomes admin · none · none seen. |
| 97 | 98 | - `/fleet/graph` — `pages/fleet-graph.ts` · the fleet as a graph · `/family`, `/forges` · none seen. |
| 98 | 99 | - `/fleet` — `pages/fleet.ts` · the rows a fleet query line selects, as a table · `/fleet/graph` · none seen. |
docs/features/returning-visitors.md added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | # Returning visitors | |
| 2 | ||
| 3 | Who comes back, how often, and what they did before they started or bought a forge (#11265). | |
| 4 | Settings → Traffic ("Who comes back"), the Billing page ("What led to a forge") and | |
| 5 | `GET /api/v1/admin/traffic/visitors?days=30` (admin token) answer the same; the API's `probe` counts the stored rows again. | |
| 6 | ||
| 7 | - **Setting `traffic.returning`.** **Off on every forge** until its owner turns it on (nothing stored reads as off), on | |
| 8 | Settings → Traffic or in the terminal ("Who comes back", `t`). Nothing a child collects reaches its host. | |
| 9 | - **Cookie id (primary).** The page-view beacon's answer sets `forge_visitor`: random 128 bits signed with an HMAC of the forge's key (a value this forge did not sign is ignored, so a client cannot invent ids), `HttpOnly`, `Secure` | |
| 10 | (when the forge is https), `SameSite=Lax`, 13 months, no `Domain` (this host only). | |
| 11 | - **Probable id (fallback).** Without the cookie: a salted hash of browser family+major, OS, language and the /24 (v4) or | |
| 12 | /48 (v6) prefix; the salt lives in `visitor_salt` and rotates every 30 days. Shown as "guessed", never merged with a | |
| 13 | cookie id. A cookie-keeping browser's very first view is recorded as probable (the cookie does not exist yet); later | |
| 14 | views are the cookie id's. No client probing. | |
| 15 | - **Bounded.** A day takes at most 5000 new visitor rows (`NEW_PER_DAY`); past it nothing is kept and no cookie is set. | |
| 16 | - **Respect.** `Sec-GPC: 1`: no cookie, no visitor row (the view still counts in the anonymous daily total). `DNT: 1` counts nothing, as before. | |
| 17 | Scrapers get no id. The `forge_measure=off` cookie stores nothing at all. | |
| 18 | - **Kept.** Per visitor: first/last seen, days seen, visits, first and latest entry referrer, furthest step, and each | |
| 19 | visit's ordered paths (no query string; a gap over 30 minutes starts a new visit). No IP, no user agent. Rows not seen | |
| 20 | for 13 months are deleted by the `visitor-retention` maintenance duty. | |
| 21 | - **Becoming someone.** Spawning a forge (`POST /api/v1/forges`) links the request's cookie id to the forge name | |
| 22 | (`started a forge`; `paid` when a gift/plan code was redeemed). Stripe-paid checkouts are not linked yet. | |
| 23 | - `/privacy` says it in three lines. |
src/contract/openapi/routes/getApiV1AdminTrafficVisitors.ts added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/admin/traffic/visitors", | |
| 6 | summary: | |
| 7 | "Read who comes back (a person admin's token, any scope); query: days (1-90, default 30); answers { as_of, enabled, days, per_day: [{ day, new_cookie, returning_cookie, new_probable, returning_probable }], new_visitors, returning_visitors, returning: [{ id (shortened), probable, visits, days_seen, first_seen, last_seen, first_ref, last_ref, step, forge }], led: [{ forge, id, probable, step, first_ref, visits: [{ day, ref, pages }] }], probe: { new, returning, agrees } } where probe counts the stored visitor rows again; a probable id is a salted hash of coarse request facts, never merged with a cookie id", | |
| 8 | auth: true, | |
| 9 | role: "admin", | |
| 10 | refusals: { not_found: "the token is not a person who administers the forge" }, | |
| 11 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -55,6 +55,7 @@ export { route as getApiV1AdminShield } from "./getApiV1AdminShield.ts"; | ||
| 55 | 55 | export { route as getApiV1AdminSubscriptions } from "./getApiV1AdminSubscriptions.ts"; |
| 56 | 56 | export { route as getApiV1AdminTeams } from "./getApiV1AdminTeams.ts"; |
| 57 | 57 | export { route as getApiV1AdminTrafficProbe } from "./getApiV1AdminTrafficProbe.ts"; |
| 58 | export { route as getApiV1AdminTrafficVisitors } from "./getApiV1AdminTrafficVisitors.ts"; | |
| 58 | 59 | export { route as getApiV1Agents } from "./getApiV1Agents.ts"; |
| 59 | 60 | export { route as getApiV1AgentsUsername } from "./getApiV1AgentsUsername.ts"; |
| 60 | 61 | export { route as getApiV1AppTemplates } from "./getApiV1AppTemplates.ts"; |
src/core/migration-pins/20261111120000-visitors.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | cd373a08768e6497adfdde27984576d2abaeff0d96def19ff06c0673dc8f1f1a |
src/core/migrations/20261111120000-visitors.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | // Who comes back (#11265): one row per visitor id (a first-party cookie id, or | |
| 2 | // a "probable" salted hash of coarse request facts), and one per visit with the | |
| 3 | // ordered paths. No address, no full user agent. Additive. | |
| 4 | export default ` | |
| 5 | CREATE TABLE visitor ( | |
| 6 | id TEXT PRIMARY KEY, | |
| 7 | kind TEXT NOT NULL, | |
| 8 | first_seen INTEGER NOT NULL, | |
| 9 | last_seen INTEGER NOT NULL, | |
| 10 | days_seen INTEGER NOT NULL, | |
| 11 | visits INTEGER NOT NULL, | |
| 12 | first_ref TEXT NOT NULL, | |
| 13 | last_ref TEXT NOT NULL, | |
| 14 | step INTEGER NOT NULL DEFAULT 0, | |
| 15 | forge TEXT NOT NULL DEFAULT '', | |
| 16 | linked_at INTEGER NOT NULL DEFAULT 0 | |
| 17 | ) WITHOUT ROWID; | |
| 18 | CREATE INDEX visitor_last_seen ON visitor (last_seen); | |
| 19 | CREATE INDEX visitor_forge ON visitor (forge) WHERE forge <> ''; | |
| 20 | CREATE TABLE visitor_visit ( | |
| 21 | id INTEGER PRIMARY KEY, | |
| 22 | visitor TEXT NOT NULL, | |
| 23 | day TEXT NOT NULL, | |
| 24 | started INTEGER NOT NULL, | |
| 25 | last INTEGER NOT NULL, | |
| 26 | entry_ref TEXT NOT NULL, | |
| 27 | pages TEXT NOT NULL | |
| 28 | ); | |
| 29 | CREATE INDEX visitor_visit_visitor ON visitor_visit (visitor, id); | |
| 30 | CREATE INDEX visitor_visit_day ON visitor_visit (day); | |
| 31 | CREATE TABLE visitor_salt ( | |
| 32 | period INTEGER PRIMARY KEY, | |
| 33 | salt TEXT NOT NULL | |
| 34 | ); | |
| 35 | `; |
src/core/reserved.ts +1
| @@ -35,6 +35,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 35 | 35 | "crew", |
| 36 | 36 | "agent", |
| 37 | 37 | "agents", |
| 38 | "privacy", | |
| 38 | 39 | "bot", |
| 39 | 40 | "steward", |
| 40 | 41 | "anonymous", |
src/http/api/admin.ts +40
| @@ -10,6 +10,8 @@ import { crawlReport } from "../../perf/crawlers.ts"; | ||
| 10 | 10 | import { getAppById } from "../../core/apps.ts"; |
| 11 | 11 | import { FUNNEL_SCOPE, daysOf, funnelReport } from "../../perf/funnel.ts"; |
| 12 | 12 | import { probeViews } from "../../perf/views_probe.ts"; |
| 13 | import { daysOf as visitorDaysOf, probeVisitors, visitorsReport } from "../../perf/visitor_report.ts"; | |
| 14 | import { getReturning } from "../../settings/returning.ts"; | |
| 13 | 15 | import { kpiProbe } from "../../web/studio/kpi_probe.ts"; |
| 14 | 16 | import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts"; |
| 15 | 17 | import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts"; |
| @@ -179,6 +181,44 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 179 | 181 | return json(only === null ? probed : { ...probed, numbers: probed.numbers.filter((n) => n.id === only) }); |
| 180 | 182 | }); |
| 181 | 183 | |
| 184 | // Who comes back (`perf/visitor_report.ts`, #11265): the same figures as the Traffic page. | |
| 185 | router.add("GET", "/api/v1/admin/traffic/visitors", (ctx) => { | |
| 186 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 187 | const denied = accountAdmin(ctx, "ro"); | |
| 188 | if (denied) return denied; | |
| 189 | const now = Date.now(); | |
| 190 | const days = visitorDaysOf(ctx.url.searchParams.get("days")); | |
| 191 | const r = visitorsReport(ctx.app.db, days, now); | |
| 192 | return json({ | |
| 193 | as_of: new Date(now).toISOString(), | |
| 194 | enabled: getReturning(ctx.app.db), | |
| 195 | days: r.days, | |
| 196 | per_day: r.perDay.map((d) => ({ | |
| 197 | day: d.day, | |
| 198 | new_cookie: d.newCookie, | |
| 199 | returning_cookie: d.returningCookie, | |
| 200 | new_probable: d.newProbable, | |
| 201 | returning_probable: d.returningProbable, | |
| 202 | })), | |
| 203 | new_visitors: r.newVisitors, | |
| 204 | returning_visitors: r.returningVisitors, | |
| 205 | returning: r.returning.map((v) => ({ | |
| 206 | id: v.id, | |
| 207 | probable: v.probable, | |
| 208 | visits: v.visits, | |
| 209 | days_seen: v.daysSeen, | |
| 210 | first_seen: v.firstSeen, | |
| 211 | last_seen: v.lastSeen, | |
| 212 | first_ref: v.firstRef, | |
| 213 | last_ref: v.lastRef, | |
| 214 | step: v.step, | |
| 215 | forge: v.forge, | |
| 216 | })), | |
| 217 | led: r.led.map((l) => ({ forge: l.forge, id: l.id, probable: l.probable, step: l.step, first_ref: l.firstRef, visits: l.visits })), | |
| 218 | probe: probeVisitors(ctx.app.db, days, now, r), | |
| 219 | }); | |
| 220 | }); | |
| 221 | ||
| 182 | 222 | // The forge's capacity (`core/capacity.ts`): read and changed live by an |
| 183 | 223 | // admin, never by an agent; anyone else is told the door is not there. |
| 184 | 224 | router.add("GET", "/api/v1/admin/capacity", (ctx) => { |
src/http/measure.ts +5 −2
| @@ -17,6 +17,7 @@ import { readBounded } from "./limits.ts"; | ||
| 17 | 17 | import { clientAddress, clientBucket } from "./peer.ts"; |
| 18 | 18 | import { originOf } from "../perf/sources.ts"; |
| 19 | 19 | import { noteView } from "../perf/views_store.ts"; |
| 20 | import { trackVisitor } from "./visitor.ts"; | |
| 20 | 21 | |
| 21 | 22 | const PER_CLIENT = 30; |
| 22 | 23 | const PER_FORGE = 1200; |
| @@ -65,13 +66,15 @@ export async function measureSubmit(ctx: Ctx, routeOf: RouteOf): Promise<Respons | ||
| 65 | 66 | clientAddress(request), |
| 66 | 67 | request.headers.get("user-agent") ?? "", |
| 67 | 68 | ); |
| 69 | const origin = originOf(report.ref, report.q, ctx.url.host, report.out); | |
| 68 | 70 | noteView(app.db, { |
| 69 | 71 | scope: "forge", |
| 70 | 72 | // The route pattern, never the path: a path can name a private repository. |
| 71 | 73 | path: route, |
| 72 | origin: originOf(report.ref, report.q, ctx.url.host, report.out), | |
| 74 | origin, | |
| 73 | 75 | client: clientAddress(request), |
| 74 | 76 | agent: request.headers.get("user-agent") ?? "", |
| 75 | 77 | }); |
| 76 | return refuse(204); | |
| 78 | const cookie = trackVisitor(app, request, ctx.url, { route, search: report.q, ref: origin.refHost || origin.source }); | |
| 79 | return refuse(204, cookie === undefined ? undefined : { "set-cookie": cookie }); | |
| 77 | 80 | } |
src/http/server.ts +2
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | import { secureFor } from "./cookie_secure.ts"; |
| 18 | 18 | import { refusedByDatabase, shownRefusal } from "./refusal.ts"; |
| 19 | 19 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 20 | import { privacyPage } from "../web/pages/privacy.ts"; | |
| 20 | 21 | import { startClaimAccountSubmit, startClaimPage, startClaimSubmit } from "../web/pages/start_claim.ts"; |
| 21 | 22 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| 22 | 23 | import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts"; |
| @@ -939,6 +940,7 @@ export function buildRouter(): Router<Handler> { | ||
| 939 | 940 | router.add("GET", "/audit", auditPage); |
| 940 | 941 | router.add("GET", "/releases", releasesPage); |
| 941 | 942 | // The forge's own documentation: a redirect built from configuration only. |
| 943 | router.add("GET", "/privacy", privacyPage); | |
| 942 | 944 | router.add("GET", "/docs", docsFolder); |
| 943 | 945 | router.add("GET", "/docs/:file", docsFile); |
| 944 | 946 | router.add("GET", "/changelog", changelogPage); |
src/http/settings_routes.ts +2
| @@ -26,6 +26,7 @@ import { spendPage } from "../web/pages/spend-tab.ts"; | ||
| 26 | 26 | import { securityControlsPage } from "../web/pages/security_controls.ts"; |
| 27 | 27 | import { securityPage, securityShieldSet } from "../web/pages/security.ts"; |
| 28 | 28 | import { trafficPage } from "../web/pages/traffic.ts"; |
| 29 | import { returningSet } from "../web/pages/visitors.ts"; | |
| 29 | 30 | import { sourcesPage } from "../web/pages/sources.ts"; |
| 30 | 31 | import { requestAct } from "../web/requests_card.ts"; |
| 31 | 32 | import { captureStart, captureStop, networksImport, scrapesExport, scrapesPage } from "../web/pages/scrapes.ts"; |
| @@ -235,6 +236,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 235 | 236 | router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns)); |
| 236 | 237 | router.add("GET", "/settings/spend", spendPage); |
| 237 | 238 | router.add("GET", "/settings/traffic", trafficPage); |
| 239 | router.add("POST", "/settings/traffic/returning", returningSet); | |
| 238 | 240 | router.add("GET", "/settings/traffic/sources", sourcesPage); |
| 239 | 241 | router.add("GET", "/settings/traffic/scrapes", scrapesPage); |
| 240 | 242 | router.add("GET", "/settings/traffic/scrapes/export", scrapesExport); |
src/http/visitor.ts added +46
| @@ -0,0 +1,46 @@ | ||
| 1 | /** | |
| 2 | * The returning-visitor step of the page-view beacon (#11265): decide who the | |
| 3 | * request is, store one view, and say whether a cookie is to be set. | |
| 4 | * | |
| 5 | * Nothing is kept when the forge's `traffic.returning` is off, when the | |
| 6 | * request carries `Sec-GPC: 1` or `DNT: 1`, or when the agent is a scraper | |
| 7 | * (`isScraperAgent`: counted apart, never given an id). A signed cookie id is the | |
| 8 | * primary signal; without one the visitor is a "probable" salted hash, and the | |
| 9 | * cookie is set so the next view is exact. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { App } from "../app.ts"; | |
| 13 | import { getReturning } from "../settings/returning.ts"; | |
| 14 | import { isScraperAgent } from "../perf/sources.ts"; | |
| 15 | import { loadKey } from "../kernel/secret.ts"; | |
| 16 | import { cookieId, cookieLine, mintId, probableId, refusesTracking, stepOf } from "../perf/visitor_id.ts"; | |
| 17 | import { noteVisitor, visitorSalt } from "../perf/visitor_store.ts"; | |
| 18 | import { clientAddress } from "./peer.ts"; | |
| 19 | import { secureFor } from "./cookie_secure.ts"; | |
| 20 | ||
| 21 | /** Returns the Set-Cookie line to add to the answer, or undefined. */ | |
| 22 | export function trackVisitor( | |
| 23 | app: App, | |
| 24 | request: Request, | |
| 25 | url: URL, | |
| 26 | view: { route: string; search: string | undefined; ref: string }, | |
| 27 | at: number = Date.now(), | |
| 28 | ): string | undefined { | |
| 29 | if (!getReturning(app.db)) return undefined; | |
| 30 | if (refusesTracking(request.headers)) return undefined; | |
| 31 | if (isScraperAgent(request.headers.get("user-agent") ?? "")) return undefined; | |
| 32 | const key = loadKey(app.config.dataDir); | |
| 33 | const held = cookieId(request.headers.get("cookie"), key); | |
| 34 | const minted = held === undefined ? mintId(key) : undefined; | |
| 35 | const id = held ?? probableId(visitorSalt(app.db, at), request.headers, clientAddress(request)); | |
| 36 | const kept = noteVisitor(app.db, { | |
| 37 | id, | |
| 38 | kind: held === undefined ? "probable" : "cookie", | |
| 39 | path: view.route, | |
| 40 | ref: view.ref, | |
| 41 | step: stepOf(view.route, view.search), | |
| 42 | at, | |
| 43 | }); | |
| 44 | // Past today's cap on new visitors nothing is kept, so no cookie is given either. | |
| 45 | return minted === undefined || !kept ? undefined : cookieLine(minted.value, secureFor(url, app.config.url)); | |
| 46 | } |
src/maintain/duties/visitor_retention.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** Visitor rows not seen for 13 months are deleted (#11265); see `perf/visitor_store.ts`. */ | |
| 2 | ||
| 3 | import type { Duty } from "../duty.ts"; | |
| 4 | import { purgeVisitors } from "../../perf/visitor_store.ts"; | |
| 5 | ||
| 6 | export const duty: Duty = { | |
| 7 | name: "visitor-retention", | |
| 8 | intervalMs: 86_400_000, | |
| 9 | run({ db, now }) { | |
| 10 | const removed = purgeVisitors(db, now.getTime()); | |
| 11 | return { findings: [], actions: removed === 0 ? [] : [`deleted ${removed} visitor rows older than 13 months`] }; | |
| 12 | }, | |
| 13 | }; |
src/maintain/registry.ts +2 −1
| @@ -5,5 +5,6 @@ import { duty as pullsWaiting } from "./duties/stale_pulls.ts"; | ||
| 5 | 5 | import { duty as ruleWatcher } from "./duties/rule_watcher.ts"; |
| 6 | 6 | import { duty as efficiencyWatcher } from "./duties/efficiency_watcher.ts"; |
| 7 | 7 | import { duty as flowKeeper } from "./duties/flow_keeper.ts"; |
| 8 | import { duty as visitorRetention } from "./duties/visitor_retention.ts"; | |
| 8 | 9 | |
| 9 | export const duties: readonly Duty[] = [pullsWaiting, ruleWatcher, efficiencyWatcher, flowKeeper]; | |
| 10 | export const duties: readonly Duty[] = [pullsWaiting, ruleWatcher, efficiencyWatcher, flowKeeper, visitorRetention]; |
src/perf/visitor_id.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * Who a request is, for the returning-visitor view (#11265). Plain data in, | |
| 3 | * plain data out; no SQL here. | |
| 4 | * | |
| 5 | * The primary signal is a random id in a first-party cookie (`forge_visitor`): | |
| 6 | * exact, signed so only this forge's own ids are accepted, and it carries nothing about the person. Without it the fallback is a | |
| 7 | * salted hash of coarse facts the server already sees (browser family and | |
| 8 | * major version, OS, language, the network prefix: /24 for v4, /48 for v6), | |
| 9 | * marked "probable" wherever it is shown and never merged with a cookie id. | |
| 10 | * The salt rotates every 30 days (`visitor_store.ts`). Nothing is probed on the | |
| 11 | * client: no canvas, fonts, audio or WebGL. | |
| 12 | */ | |
| 13 | ||
| 14 | import { createHmac, timingSafeEqual } from "node:crypto"; | |
| 15 | import { sha256 } from "../kernel/hash.ts"; | |
| 16 | import { addressBytes } from "../kernel/cidr.ts"; | |
| 17 | ||
| 18 | export const COOKIE = "forge_visitor"; | |
| 19 | /** 13 months, in seconds. */ | |
| 20 | export const COOKIE_MAX_AGE = 396 * 86_400; | |
| 21 | export const SALT_MS = 30 * 86_400_000; | |
| 22 | const COOKIE_ID = /^c[0-9a-f]{32}\.[0-9a-f]{32}$/; | |
| 23 | ||
| 24 | export type VisitorKind = "cookie" | "probable"; | |
| 25 | ||
| 26 | const macOf = (key: Uint8Array, id: string): string => createHmac("sha256", key).update(`visitor-cookie\0${id}`).digest("hex").slice(0, 32); | |
| 27 | ||
| 28 | /** | |
| 29 | * A new random id and the cookie value that carries it: `c<128 random bits>.<32 hex of an HMAC of the id>`. | |
| 30 | * Only a value this forge signed is accepted back (`cookieId`), so a client cannot invent ids to fill the table. | |
| 31 | */ | |
| 32 | export function mintId(key: Uint8Array): { readonly id: string; readonly value: string } { | |
| 33 | const bytes = crypto.getRandomValues(new Uint8Array(16)); | |
| 34 | const id = `c${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`; | |
| 35 | return { id, value: `${id}.${macOf(key, id)}` }; | |
| 36 | } | |
| 37 | ||
| 38 | /** The id in a cookie header, only if its signature is one this forge made. */ | |
| 39 | export function cookieId(header: string | null, key: Uint8Array): string | undefined { | |
| 40 | if (header === null) return undefined; | |
| 41 | for (const pair of header.split(";")) { | |
| 42 | const eq = pair.indexOf("="); | |
| 43 | if (eq === -1 || pair.slice(0, eq).trim() !== COOKIE) continue; | |
| 44 | const value = pair.slice(eq + 1).trim(); | |
| 45 | if (!COOKIE_ID.test(value)) return undefined; | |
| 46 | const [id, mac] = value.split(".") as [string, string]; | |
| 47 | return timingSafeEqual(Buffer.from(mac), Buffer.from(macOf(key, id))) ? id : undefined; | |
| 48 | } | |
| 49 | return undefined; | |
| 50 | } | |
| 51 | ||
| 52 | /** `Sec-GPC: 1` or `DNT: 1`: no cookie and no stored visitor. */ | |
| 53 | export const refusesTracking = (headers: Headers): boolean => | |
| 54 | headers.get("sec-gpc") === "1" || headers.get("dnt") === "1"; | |
| 55 | ||
| 56 | /** Browser family and major version: "chrome/120". Order matters: Edge and Opera also say Chrome. */ | |
| 57 | export function browserOf(agent: string): string { | |
| 58 | const a = agent.slice(0, 300); | |
| 59 | const pick = (name: string, re: RegExp): string | undefined => { | |
| 60 | const m = re.exec(a); | |
| 61 | return m === null ? undefined : `${name}/${m[1]}`; | |
| 62 | }; | |
| 63 | return ( | |
| 64 | pick("edge", /\bEdg(?:e|A|iOS)?\/(\d+)/) ?? | |
| 65 | pick("opera", /\bOPR\/(\d+)/) ?? | |
| 66 | pick("firefox", /\b(?:Firefox|FxiOS)\/(\d+)/) ?? | |
| 67 | pick("chrome", /\b(?:Chrome|CriOS)\/(\d+)/) ?? | |
| 68 | pick("safari", /\bVersion\/(\d+)[\d.]* (?:Mobile\/\S+ )?Safari\//) ?? | |
| 69 | "other" | |
| 70 | ); | |
| 71 | } | |
| 72 | ||
| 73 | export function osOf(agent: string): string { | |
| 74 | const a = agent.slice(0, 300); | |
| 75 | if (/Windows/.test(a)) return "windows"; | |
| 76 | if (/Android/.test(a)) return "android"; | |
| 77 | if (/iPhone|iPad|iPod/.test(a)) return "ios"; | |
| 78 | if (/Mac OS X|Macintosh/.test(a)) return "macos"; | |
| 79 | if (/CrOS/.test(a)) return "chromeos"; | |
| 80 | if (/Linux|X11/.test(a)) return "linux"; | |
| 81 | return "other"; | |
| 82 | } | |
| 83 | ||
| 84 | /** The first language tag, lowercase, at most 8 characters. */ | |
| 85 | export const languageOf = (header: string | null): string => | |
| 86 | (header ?? "").split(",")[0]?.split(";")[0]?.trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8) ?? ""; | |
| 87 | ||
| 88 | /** The network prefix: /24 of a v4 address, /48 of a v6 one; "" when the address is unknown. */ | |
| 89 | export function prefixOf(address: string): string { | |
| 90 | const bytes = addressBytes(address); | |
| 91 | if (bytes === undefined) return ""; | |
| 92 | if (bytes.length === 4) return `${bytes[0]}.${bytes[1]}.${bytes[2]}/24`; | |
| 93 | return `${((bytes[0]! << 8) | bytes[1]!).toString(16)}:${((bytes[2]! << 8) | bytes[3]!).toString(16)}:${((bytes[4]! << 8) | bytes[5]!).toString(16)}/48`; | |
| 94 | } | |
| 95 | ||
| 96 | /** The probable id: "p" and a salted hash of the coarse facts. The salt is the 30-day one. */ | |
| 97 | export function probableId(salt: string, headers: Headers, address: string): string { | |
| 98 | const agent = headers.get("user-agent") ?? ""; | |
| 99 | const facts = [browserOf(agent), osOf(agent), languageOf(headers.get("accept-language")), prefixOf(address)].join("\0"); | |
| 100 | return `p${sha256(`${salt}\0visitor\0${facts}`).slice(0, 32)}`; | |
| 101 | } | |
| 102 | ||
| 103 | /** The Set-Cookie line for a new id: HttpOnly, Secure when the forge is https, SameSite=Lax, 13 months, this host only (no Domain). */ | |
| 104 | export const cookieLine = (value: string, secure: boolean): string => | |
| 105 | `${COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}${secure ? "; Secure" : ""}`; | |
| 106 | ||
| 107 | /** How far a visitor got; the highest step seen is kept. */ | |
| 108 | export const STEPS = ["none", "saw plans", "clicked a plan", "started a forge", "paid"] as const; | |
| 109 | ||
| 110 | /** The step a page view shows: the front page and /agents show the plans; a plan chosen is a spawn or a code. */ | |
| 111 | export function stepOf(route: string, search: string | undefined): number { | |
| 112 | if (route.startsWith("/spawn") || route.startsWith("/signup") || /(?:^|[?&])(?:code|plan)=/.test(search ?? "")) return 2; | |
| 113 | if (route === "/" || route === "/agents" || route === "/plans") return 1; | |
| 114 | return 0; | |
| 115 | } |
src/perf/visitor_link.ts added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | /** | |
| 2 | * Linking a request's visitor to the forge it started (#11265). Kept apart from | |
| 3 | * the http layer so the spawn door can call it without importing from it. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { App } from "../app.ts"; | |
| 7 | import { loadKey } from "../kernel/secret.ts"; | |
| 8 | import { getReturning } from "../settings/returning.ts"; | |
| 9 | import { cookieId, refusesTracking } from "./visitor_id.ts"; | |
| 10 | import { linkVisitor } from "./visitor_store.ts"; | |
| 11 | ||
| 12 | /** Link the request's cookie visitor to a forge it started. Quiet when there is no signed cookie, or tracking is off or declined. */ | |
| 13 | export function linkRequestVisitor(app: App, request: Request, forge: string, step: "started" | "paid"): void { | |
| 14 | if (!getReturning(app.db) || refusesTracking(request.headers)) return; | |
| 15 | const id = cookieId(request.headers.get("cookie"), loadKey(app.config.dataDir)); | |
| 16 | if (id !== undefined) linkVisitor(app.db, id, forge, step, Date.now()); | |
| 17 | } |
src/perf/visitor_report.ts added +183
| @@ -0,0 +1,183 @@ | ||
| 1 | /** | |
| 2 | * The returning-visitor figures (#11265), read from `visitor` and | |
| 3 | * `visitor_visit`: new versus returning per day, the returning list, and for a | |
| 4 | * forge that was started or paid, the visits that led to it. Cookie ids and | |
| 5 | * probable ids are always counted apart: a probable visitor is never merged | |
| 6 | * with a cookie one. Plain data out. | |
| 7 | * | |
| 8 | * Counts: a day's visitor is a distinct visitor id with a visit that day; it is | |
| 9 | * new when its first_seen is that day, returning otherwise. In the totals a | |
| 10 | * returning visitor is one with two visits or more, last seen in the window. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { Database } from "bun:sqlite"; | |
| 14 | import { dayOf } from "../web/measure.ts"; | |
| 15 | import { STEPS, type VisitorKind } from "./visitor_id.ts"; | |
| 16 | ||
| 17 | const DAY_MS = 86_400_000; | |
| 18 | export const DAYS_MAX = 90; | |
| 19 | export const LIST_MAX = 50; | |
| 20 | ||
| 21 | export const daysOf = (raw: string | null): number => { | |
| 22 | const n = Number(raw); | |
| 23 | return Number.isInteger(n) && n >= 1 && n <= DAYS_MAX ? n : 30; | |
| 24 | }; | |
| 25 | ||
| 26 | export interface DayCount { | |
| 27 | readonly day: string; | |
| 28 | readonly newCookie: number; | |
| 29 | readonly returningCookie: number; | |
| 30 | readonly newProbable: number; | |
| 31 | readonly returningProbable: number; | |
| 32 | } | |
| 33 | ||
| 34 | export interface Visitor { | |
| 35 | /** Shortened: the first 8 characters. */ | |
| 36 | readonly id: string; | |
| 37 | readonly kind: VisitorKind; | |
| 38 | /** True for a probable id: shown as "probable" wherever it appears. */ | |
| 39 | readonly probable: boolean; | |
| 40 | readonly visits: number; | |
| 41 | readonly daysSeen: number; | |
| 42 | readonly firstSeen: string; | |
| 43 | readonly lastSeen: string; | |
| 44 | readonly firstRef: string; | |
| 45 | readonly lastRef: string; | |
| 46 | readonly step: string; | |
| 47 | readonly forge: string; | |
| 48 | } | |
| 49 | ||
| 50 | export interface Led { | |
| 51 | readonly forge: string; | |
| 52 | readonly id: string; | |
| 53 | readonly probable: boolean; | |
| 54 | readonly step: string; | |
| 55 | readonly firstSeen: string; | |
| 56 | readonly firstRef: string; | |
| 57 | readonly visits: readonly { readonly day: string; readonly ref: string; readonly pages: readonly string[] }[]; | |
| 58 | } | |
| 59 | ||
| 60 | export interface VisitorsReport { | |
| 61 | readonly days: number; | |
| 62 | readonly perDay: readonly DayCount[]; | |
| 63 | readonly newVisitors: number; | |
| 64 | readonly returningVisitors: number; | |
| 65 | readonly returning: readonly Visitor[]; | |
| 66 | readonly led: readonly Led[]; | |
| 67 | } | |
| 68 | ||
| 69 | const iso = (ms: number): string => new Date(ms).toISOString().slice(0, 10); | |
| 70 | const shortId = (id: string): string => id.slice(0, 9); | |
| 71 | ||
| 72 | interface Row { | |
| 73 | id: string; | |
| 74 | kind: VisitorKind; | |
| 75 | first_seen: number; | |
| 76 | last_seen: number; | |
| 77 | days_seen: number; | |
| 78 | visits: number; | |
| 79 | first_ref: string; | |
| 80 | last_ref: string; | |
| 81 | step: number; | |
| 82 | forge: string; | |
| 83 | } | |
| 84 | ||
| 85 | const asVisitor = (r: Row): Visitor => ({ | |
| 86 | id: shortId(r.id), | |
| 87 | kind: r.kind, | |
| 88 | probable: r.kind === "probable", | |
| 89 | visits: r.visits, | |
| 90 | daysSeen: r.days_seen, | |
| 91 | firstSeen: iso(r.first_seen), | |
| 92 | lastSeen: iso(r.last_seen), | |
| 93 | firstRef: r.first_ref === "" ? "(direct)" : r.first_ref, | |
| 94 | lastRef: r.last_ref === "" ? "(direct)" : r.last_ref, | |
| 95 | step: STEPS[r.step] ?? "none", | |
| 96 | forge: r.forge, | |
| 97 | }); | |
| 98 | ||
| 99 | /** The forges a visitor started or paid, with the visits that led to each: two queries however many forges. */ | |
| 100 | export function ledReport(db: Database): readonly Led[] { | |
| 101 | const forged = db.query<Row, []>("SELECT * FROM visitor WHERE forge <> '' ORDER BY linked_at DESC LIMIT 20").all(); | |
| 102 | if (forged.length === 0) return []; | |
| 103 | const marks = forged.map(() => "?").join(","); | |
| 104 | const visits = new Map<string, { day: string; ref: string; pages: string[] }[]>(); | |
| 105 | for (const v of db | |
| 106 | .query<{ visitor: string; day: string; entry_ref: string; pages: string }, string[]>( | |
| 107 | `SELECT visitor, day, entry_ref, pages FROM visitor_visit WHERE visitor IN (${marks}) ORDER BY id`, | |
| 108 | ) | |
| 109 | .all(...forged.map((r) => r.id))) { | |
| 110 | const list = visits.get(v.visitor) ?? []; | |
| 111 | if (list.length < 20) list.push({ day: v.day, ref: v.entry_ref === "" ? "(direct)" : v.entry_ref, pages: JSON.parse(v.pages) as string[] }); | |
| 112 | visits.set(v.visitor, list); | |
| 113 | } | |
| 114 | return forged.map((r) => ({ | |
| 115 | forge: r.forge, | |
| 116 | id: shortId(r.id), | |
| 117 | probable: r.kind === "probable", | |
| 118 | step: STEPS[r.step] ?? "none", | |
| 119 | firstSeen: iso(r.first_seen), | |
| 120 | firstRef: r.first_ref === "" ? "(direct)" : r.first_ref, | |
| 121 | visits: visits.get(r.id) ?? [], | |
| 122 | })); | |
| 123 | } | |
| 124 | ||
| 125 | export function visitorsReport(db: Database, days: number, now: number): VisitorsReport { | |
| 126 | const since = dayOf(now - (days - 1) * DAY_MS); | |
| 127 | const per = db | |
| 128 | .query<{ day: string; kind: VisitorKind; fresh: number; n: number }, [string]>( | |
| 129 | `SELECT v.day AS day, w.kind AS kind, (strftime('%Y-%m-%d', w.first_seen / 1000, 'unixepoch') = v.day) AS fresh, | |
| 130 | COUNT(DISTINCT v.visitor) AS n | |
| 131 | FROM visitor_visit v JOIN visitor w ON w.id = v.visitor | |
| 132 | WHERE v.day >= ?1 GROUP BY v.day, w.kind, fresh`, | |
| 133 | ) | |
| 134 | .all(since); | |
| 135 | const byDay = new Map<string, { nc: number; rc: number; np: number; rp: number }>(); | |
| 136 | for (const r of per) { | |
| 137 | const d = byDay.get(r.day) ?? { nc: 0, rc: 0, np: 0, rp: 0 }; | |
| 138 | if (r.kind === "cookie") r.fresh === 1 ? (d.nc += r.n) : (d.rc += r.n); | |
| 139 | else r.fresh === 1 ? (d.np += r.n) : (d.rp += r.n); | |
| 140 | byDay.set(r.day, d); | |
| 141 | } | |
| 142 | const perDay: DayCount[] = Array.from({ length: days }, (_, i) => { | |
| 143 | const day = iso(now - (days - 1 - i) * DAY_MS); | |
| 144 | const d = byDay.get(day) ?? { nc: 0, rc: 0, np: 0, rp: 0 }; | |
| 145 | return { day, newCookie: d.nc, returningCookie: d.rc, newProbable: d.np, returningProbable: d.rp }; | |
| 146 | }); | |
| 147 | const cut = now - days * DAY_MS; | |
| 148 | const counts = db | |
| 149 | .query<{ fresh: number; n: number }, [number]>( | |
| 150 | "SELECT (visits = 1) AS fresh, COUNT(*) AS n FROM visitor WHERE last_seen >= ?1 GROUP BY fresh", | |
| 151 | ) | |
| 152 | .all(cut); | |
| 153 | const returning = db | |
| 154 | .query<Row, [number]>( | |
| 155 | `SELECT * FROM visitor WHERE visits >= 2 AND last_seen >= ?1 ORDER BY visits DESC, last_seen DESC LIMIT ${LIST_MAX}`, | |
| 156 | ) | |
| 157 | .all(cut) | |
| 158 | .map(asVisitor); | |
| 159 | const led = ledReport(db); | |
| 160 | return { | |
| 161 | days, | |
| 162 | perDay, | |
| 163 | newVisitors: counts.find((c) => c.fresh === 1)?.n ?? 0, | |
| 164 | returningVisitors: counts.find((c) => c.fresh === 0)?.n ?? 0, | |
| 165 | returning, | |
| 166 | led, | |
| 167 | }; | |
| 168 | } | |
| 169 | ||
| 170 | /** The same counts by a different route: the probe proves a report's numbers against their rows, without building the report again. */ | |
| 171 | export function probeVisitors( | |
| 172 | db: Database, | |
| 173 | days: number, | |
| 174 | now: number, | |
| 175 | against: { readonly newVisitors: number; readonly returningVisitors: number }, | |
| 176 | ): { readonly new: number; readonly returning: number; readonly agrees: boolean } { | |
| 177 | const cut = now - days * DAY_MS; | |
| 178 | const n = (where: string): number => | |
| 179 | db.query<{ n: number }, [number]>(`SELECT COUNT(*) AS n FROM visitor WHERE last_seen >= ?1 AND ${where}`).get(cut)?.n ?? 0; | |
| 180 | const fresh = n("visits = 1"); | |
| 181 | const back = n("visits >= 2"); | |
| 182 | return { new: fresh, returning: back, agrees: fresh === against.newVisitors && back === against.returningVisitors }; | |
| 183 | } |
src/perf/visitor_store.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * What is kept of a returning visitor (#11265): per visitor id, first and last | |
| 3 | * seen, days seen, visit count, the entry referrer of the first and latest | |
| 4 | * visit, the furthest step reached, and the ordered paths of each visit (path | |
| 5 | * only, no query string). Never an address or a user agent: the probable id is | |
| 6 | * a salted hash and the salt (30 days) is the only thing that could undo it. | |
| 7 | * | |
| 8 | * A visit is a run of views with no gap over 30 minutes. A row untouched for | |
| 9 | * 13 months is deleted (`purgeVisitors`, run by the maintenance duty). | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { dayOf } from "../web/measure.ts"; | |
| 14 | import { SALT_MS, STEPS, type VisitorKind } from "./visitor_id.ts"; | |
| 15 | ||
| 16 | export const VISIT_GAP_MS = 30 * 60_000; | |
| 17 | export const PAGES_MAX = 40; | |
| 18 | export const KEEP_MS = 396 * 86_400_000; | |
| 19 | const REF_MAX = 100; | |
| 20 | /** New visitor rows a forge takes in one day; a beacon past it keeps nothing and sets no cookie. */ | |
| 21 | export const NEW_PER_DAY = 5000; | |
| 22 | ||
| 23 | /** The 30-day salt: made on first use, the one before kept so a reading near the turn is not blind, older ones deleted. */ | |
| 24 | export function visitorSalt(db: Database, at: number): string { | |
| 25 | const period = Math.floor(at / SALT_MS); | |
| 26 | const found = db.query<{ salt: string }, [number]>("SELECT salt FROM visitor_salt WHERE period = ?1").get(period); | |
| 27 | if (found !== null) return found.salt; | |
| 28 | const salt = Array.from(crypto.getRandomValues(new Uint8Array(24)), (b) => b.toString(16).padStart(2, "0")).join(""); | |
| 29 | db.transaction(() => { | |
| 30 | db.query("INSERT OR IGNORE INTO visitor_salt (period, salt) VALUES (?1, ?2)").run(period, salt); | |
| 31 | db.query("DELETE FROM visitor_salt WHERE period < ?1").run(period - 1); | |
| 32 | })(); | |
| 33 | return db.query<{ salt: string }, [number]>("SELECT salt FROM visitor_salt WHERE period = ?1").get(period)!.salt; | |
| 34 | } | |
| 35 | ||
| 36 | export interface Seen { | |
| 37 | readonly id: string; | |
| 38 | readonly kind: VisitorKind; | |
| 39 | readonly path: string; | |
| 40 | readonly ref: string; | |
| 41 | readonly step: number; | |
| 42 | readonly at: number; | |
| 43 | } | |
| 44 | ||
| 45 | /** Record one page view of a visitor. False when the visitor is new and today's cap on new visitors is spent: nothing is kept. */ | |
| 46 | export function noteVisitor(db: Database, v: Seen, cap: number = NEW_PER_DAY): boolean { | |
| 47 | const ref = v.ref.slice(0, REF_MAX); | |
| 48 | const path = (v.path.split(/[?#]/)[0] ?? "/").slice(0, 200); | |
| 49 | return db.transaction(() => { | |
| 50 | const known = db.query<{ last_seen: number }, [string]>("SELECT last_seen FROM visitor WHERE id = ?1").get(v.id); | |
| 51 | if (known === null) { | |
| 52 | const today = db | |
| 53 | .query<{ n: number }, [string, number]>( | |
| 54 | "SELECT COUNT(DISTINCT v.visitor) AS n FROM visitor_visit v JOIN visitor w ON w.id = v.visitor WHERE v.day = ?1 AND w.first_seen >= ?2", | |
| 55 | ) | |
| 56 | .get(dayOf(v.at), Math.floor(v.at / 86_400_000) * 86_400_000)?.n ?? 0; | |
| 57 | if (today >= cap) return false; | |
| 58 | } | |
| 59 | const last = db | |
| 60 | .query<{ id: number; last: number; pages: string }, [string]>("SELECT id, last, pages FROM visitor_visit WHERE visitor = ?1 ORDER BY id DESC LIMIT 1") | |
| 61 | .get(v.id); | |
| 62 | const continues = last !== null && v.at - last.last <= VISIT_GAP_MS; | |
| 63 | if (known === null) { | |
| 64 | db.query( | |
| 65 | `INSERT INTO visitor (id, kind, first_seen, last_seen, days_seen, visits, first_ref, last_ref, step) | |
| 66 | VALUES (?1, ?2, ?3, ?3, 1, 1, ?4, ?4, ?5)`, | |
| 67 | ).run(v.id, v.kind, v.at, ref, v.step); | |
| 68 | } else { | |
| 69 | db.query( | |
| 70 | `UPDATE visitor SET last_seen = ?2, step = MAX(step, ?3), | |
| 71 | days_seen = days_seen + ?4, visits = visits + ?5, last_ref = CASE WHEN ?5 = 1 THEN ?6 ELSE last_ref END | |
| 72 | WHERE id = ?1`, | |
| 73 | ).run(v.id, Math.max(known.last_seen, v.at), v.step, dayOf(known.last_seen) === dayOf(v.at) ? 0 : 1, continues ? 0 : 1, ref); | |
| 74 | } | |
| 75 | if (continues && last !== null) { | |
| 76 | const pages = JSON.parse(last.pages) as string[]; | |
| 77 | if (pages.length < PAGES_MAX) pages.push(path); | |
| 78 | db.query("UPDATE visitor_visit SET last = ?2, pages = ?3 WHERE id = ?1").run(last.id, v.at, JSON.stringify(pages)); | |
| 79 | } else { | |
| 80 | db.query("INSERT INTO visitor_visit (visitor, day, started, last, entry_ref, pages) VALUES (?1, ?2, ?3, ?3, ?4, ?5)").run( | |
| 81 | v.id, | |
| 82 | dayOf(v.at), | |
| 83 | v.at, | |
| 84 | ref, | |
| 85 | JSON.stringify([path]), | |
| 86 | ); | |
| 87 | } | |
| 88 | return true; | |
| 89 | })(); | |
| 90 | } | |
| 91 | ||
| 92 | /** Starting or buying a forge makes the visitor someone: its id is linked to the forge's name, on the host's side only. */ | |
| 93 | export function linkVisitor(db: Database, id: string, forge: string, step: "started" | "paid", at: number): boolean { | |
| 94 | const n = STEPS.indexOf(step === "paid" ? "paid" : "started a forge"); | |
| 95 | const done = db | |
| 96 | .query("UPDATE visitor SET forge = ?2, linked_at = ?3, step = MAX(step, ?4) WHERE id = ?1 AND (forge = '' OR forge = ?2)") | |
| 97 | .run(id, forge.slice(0, 100), at, n); | |
| 98 | return done.changes === 1; | |
| 99 | } | |
| 100 | ||
| 101 | /** Rows not seen for 13 months, and their visits. Returns the visitors removed. */ | |
| 102 | export function purgeVisitors(db: Database, now: number): number { | |
| 103 | const cut = now - KEEP_MS; | |
| 104 | return db.transaction(() => { | |
| 105 | db.query("DELETE FROM visitor_visit WHERE visitor IN (SELECT id FROM visitor WHERE last_seen < ?1)").run(cut); | |
| 106 | db.query("DELETE FROM visitor_visit WHERE last < ?1").run(cut); | |
| 107 | return db.query("DELETE FROM visitor WHERE last_seen < ?1").run(cut).changes; | |
| 108 | })(); | |
| 109 | } |
src/settings/returning.ts added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | /** | |
| 2 | * `traffic.returning` (#11265): whether this forge keeps a visitor id for its | |
| 3 | * own visitors. Stored in `forge_setting` under `traffic.returning`, every | |
| 4 | * change kept in `forge_setting_change`. Nothing stored reads as `off`, on every | |
| 5 | * forge: the owner chooses it for their own visitors, on Settings → Traffic. A | |
| 6 | * child's visitors are its own: nothing it collects reaches its host. | |
| 7 | */ | |
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { writeSetting } from "./limits.ts"; | |
| 10 | ||
| 11 | const STORE_KEY = "traffic.returning"; | |
| 12 | ||
| 13 | export function getReturning(db: Database): boolean { | |
| 14 | const row = db.query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1").get(STORE_KEY); | |
| 15 | return row !== null && row.value === "on"; | |
| 16 | } | |
| 17 | ||
| 18 | export function setReturning(db: Database, on: boolean, actorId: string): void { | |
| 19 | const from = getReturning(db); | |
| 20 | if (from === on) return; | |
| 21 | writeSetting(db, STORE_KEY, on ? "on" : "off", from ? "on" : "off", on ? "on" : "off", actorId); | |
| 22 | } |
src/spawn/door.ts +3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * the caller says `false` — returns the plan and changes nothing. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { linkRequestVisitor } from "../perf/visitor_link.ts"; | |
| 9 | 10 | import { startWatch, probeOf, type Probe } from "./live.ts"; |
| 10 | 11 | import { firstProof, reachWords, settleChild } from "./reach.ts"; |
| 11 | 12 | import { reissueClaim, spawnForge, type SpawnError } from "../actions/spawn.ts"; |
| @@ -364,6 +365,8 @@ export async function spawnDoor( | ||
| 364 | 365 | } |
| 365 | 366 | if (!made.ok) return spawnWords(made.error); |
| 366 | 367 | if (anon && !made.value.existed) noteAnonSpawn(ctx.app.db, ctx.request); |
| 368 | // The visitor who started (or was given) this forge becomes someone, on the host's side (#11265). | |
| 369 | if (!made.value.existed) linkRequestVisitor(ctx.app, ctx.request, made.value.name, gift === undefined ? "started" : "paid"); | |
| 367 | 370 | // Its apps come up now, so the first visit after a claim finds them awake. |
| 368 | 371 | if (!made.value.existed) { |
| 369 | 372 | void bootHostedChild(ctx.app, made.value.name); |
src/tui/screens/entries.ts +2
| @@ -79,6 +79,7 @@ import { entry as scorecard } from "./scorecard.ts"; | ||
| 79 | 79 | import { entry as weekcard } from "./weekcard.ts"; |
| 80 | 80 | import { entry as scrapes } from "./scrapes.ts"; |
| 81 | 81 | import { entry as trafficScreenEntry } from "./traffic.ts"; |
| 82 | import { entry as returningEntry } from "./returning.ts"; | |
| 82 | 83 | import { entry as securityControls } from "./controls.ts"; |
| 83 | 84 | import { entry as secrets } from "./secrets.ts"; |
| 84 | 85 | import { entry as sentinel } from "./sentinel.ts"; |
| @@ -151,6 +152,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 151 | 152 | flowsAll, |
| 152 | 153 | scrapes, |
| 153 | 154 | trafficScreenEntry, |
| 155 | returningEntry, | |
| 154 | 156 | teams, |
| 155 | 157 | orgTeams, |
| 156 | 158 | orgGrants, |
src/tui/screens/returning.ts added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | /** | |
| 2 | * Who comes back, in the terminal (#11265): the same figures as the block on | |
| 3 | * Settings → Traffic (`perf/visitor_report.ts`), for a forge admin only, and | |
| 4 | * `t` turns the setting `traffic.returning` on or off. The setting is the one | |
| 5 | * row the web page reads on every request, so a change here is seen there at once. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 9 | import { binding } from "../app/keys.ts"; | |
| 10 | import { ledReport, visitorsReport } from "../../perf/visitor_report.ts"; | |
| 11 | import { getReturning, setReturning } from "../../settings/returning.ts"; | |
| 12 | import type { Session } from "../session.ts"; | |
| 13 | import { style } from "../term/style.ts"; | |
| 14 | import { truncate } from "../term/width.ts"; | |
| 15 | import { fill, len, split } from "../ui/layout.ts"; | |
| 16 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 17 | import type { PaletteEntry } from "./entries.ts"; | |
| 18 | ||
| 19 | const DAYS = 30; | |
| 20 | const may = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 21 | ||
| 22 | export function returningLines(f: Session, now = Date.now()): ListRow[] { | |
| 23 | if (!may(f)) return []; | |
| 24 | if (!getReturning(f.db)) return [{ title: "Off: no visitor id is kept for this forge's visitors", meta: "t turns it on" }]; | |
| 25 | const r = visitorsReport(f.db, DAYS, now); | |
| 26 | const rows: ListRow[] = [{ title: `${r.newVisitors} new and ${r.returningVisitors} returning visitors in the last ${r.days} days`, meta: "returning is two visits or more" }]; | |
| 27 | for (const v of r.returning) | |
| 28 | rows.push({ | |
| 29 | title: `${v.id}${v.probable ? " (guessed)" : ""}: ${v.visits} visits`, | |
| 30 | meta: `first ${v.firstSeen} from ${v.firstRef} · last ${v.lastSeen} · got as far as ${v.step}`, | |
| 31 | }); | |
| 32 | for (const l of ledReport(f.db)) | |
| 33 | rows.push({ title: `Forge ${l.forge}: ${l.step}`, meta: `first came from ${l.firstRef} on ${l.firstSeen} · ${l.visits.map((v) => `${v.day} ${v.pages.join(" → ")}`).join("; ")}` }); | |
| 34 | return rows; | |
| 35 | } | |
| 36 | ||
| 37 | export function returningScreen(f: Session, ctx: Ctx): Screen { | |
| 38 | const state = new ListState(); | |
| 39 | return { | |
| 40 | title: "Who comes back", | |
| 41 | keys: [binding(["j", "k"], "move cursor"), binding(["t"], "turn on or off")], | |
| 42 | render(area, s) { | |
| 43 | const c = ctx.theme.c; | |
| 44 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 45 | s.text(head!.x, head!.y, truncate(`visitor ids for this forge: ${getReturning(f.db) ? "on" : "off"}; a visitor with no cookie is guessed`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 46 | drawList(s, list!, c, state, returningLines(f)); | |
| 47 | }, | |
| 48 | key(e) { | |
| 49 | if (!may(f)) return false; | |
| 50 | if (e.key === "t") { | |
| 51 | const to = !getReturning(f.db); | |
| 52 | setReturning(f.db, to, f.user!.id); | |
| 53 | ctx.toast(to ? "Returning visitors are on." : "Returning visitors are off.", "success"); | |
| 54 | return true; | |
| 55 | } | |
| 56 | return state.key(e, returningLines(f).length); | |
| 57 | }, | |
| 58 | }; | |
| 59 | } | |
| 60 | ||
| 61 | export const entry: PaletteEntry = { | |
| 62 | id: "returning-visitors", | |
| 63 | title: "Who comes back", | |
| 64 | hint: "new and returning visitors, and the setting that keeps them", | |
| 65 | after: ["traffic"], | |
| 66 | offered: may, | |
| 67 | run: (session, ctx) => ctx.push(returningScreen(session, ctx)), | |
| 68 | }; |
src/web/pages/billing.ts +2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * Anyone else gets the not-found page. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { ledBlock } from "./visitors.ts"; | |
| 8 | 9 | import { BILLING_WORDS, STRIPE_WORDS, connectStripe, disconnectStripe, grantPlan, readBilling, saveSigningSecret, type BillingError, type StripeWords } from "../../actions/billing.ts"; |
| 9 | 10 | import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts"; |
| 10 | 11 | import { publicOrigin } from "../../core/public_origin.ts"; |
| @@ -186,6 +187,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = { | ||
| 186 | 187 | ? html`<p class="muted">No forge has been sent from here.</p>` |
| 187 | 188 | : html`<ul class="list setting-list">${rows}</ul>`} |
| 188 | 189 | </section> |
| 190 | ${ledBlock(ctx)} | |
| 189 | 191 | ${funnelRow(funnelReport(ctx.app.db, 7))} |
| 190 | 192 | ${linksSection(ctx, links)} |
| 191 | 193 | ${codesSection(ctx, shown, values, error)}`; |
src/web/pages/privacy.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | /** | |
| 2 | * GET /privacy: what the forge keeps about a visitor, in three plain lines | |
| 3 | * (#11265). Public, static, server-rendered. | |
| 4 | */ | |
| 5 | ||
| 6 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 7 | import type { Ctx } from "../../http/context.ts"; | |
| 8 | import { html } from "../html.ts"; | |
| 9 | import { page } from "../layout.ts"; | |
| 10 | ||
| 11 | export function privacyPage(ctx: Ctx): Response { | |
| 12 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 13 | return timePhase(ctx.request, "render", () => | |
| 14 | page({ | |
| 15 | ctx, | |
| 16 | title: "Privacy", | |
| 17 | body: html`<div class="page-head"><h1>Privacy</h1></div> | |
| 18 | <ul class="list"> | |
| 19 | <li> | |
| 20 | Kept: a random visitor id in a first-party cookie (or, without it, a salted hash of your browser family, OS, | |
| 21 | language and network prefix), when you came, the page paths you opened (no query string) and the site you | |
| 22 | came from. Never your address or your full browser string. | |
| 23 | </li> | |
| 24 | <li> | |
| 25 | For how long: 13 months after you were last seen, then deleted. The hash's salt changes every 30 days. | |
| 26 | </li> | |
| 27 | <li> | |
| 28 | It never leaves this forge: it is shared with no one. A request with Global Privacy Control or Do Not Track | |
| 29 | gets no cookie and no visitor record, and the owner can turn this off. | |
| 30 | </li> | |
| 31 | </ul>`, | |
| 32 | }), | |
| 33 | ); | |
| 34 | } |
src/web/pages/traffic.ts +2 −1
| @@ -32,6 +32,7 @@ import { page } from "../layout.ts"; | ||
| 32 | 32 | import { TRAFFIC_TONES, trafficColumns } from "../studio/kpi_row.ts"; |
| 33 | 33 | import { row, seg } from "../ui.ts"; |
| 34 | 34 | import { accountSections } from "./origins.ts"; |
| 35 | import { visitorsBlock } from "./visitors.ts"; | |
| 35 | 36 | |
| 36 | 37 | const CHART_W = 600; |
| 37 | 38 | const CHART_H = 96; |
| @@ -192,7 +193,7 @@ export function trafficPage(ctx: Ctx): Response { | ||
| 192 | 193 | })), |
| 193 | 194 | )} |
| 194 | 195 | </div> |
| 195 | ${body}`, | |
| 196 | ${body}${visitorsBlock(ctx, now)}`, | |
| 196 | 197 | "Account settings", |
| 197 | 198 | )}`, |
| 198 | 199 | }); |
src/web/pages/visitors.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** | |
| 2 | * Who comes back (#11265), on Settings → Traffic and the billing page: new | |
| 3 | * versus returning visitors per day (drawn here as SVG, no chart library), the | |
| 4 | * returning-visitor list, and for a forge started the visits that led | |
| 5 | * to it. A visitor with no cookie (a salted hash of coarse request facts) | |
| 6 | * says "guessed" on every row and is never merged with a cookie id. Admin only | |
| 7 | * by the pages that render it. The setting `traffic.returning` is changed here. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Ctx } from "../../http/context.ts"; | |
| 11 | import { daysOf, ledReport, visitorsReport, type DayCount, type Led, type Visitor } from "../../perf/visitor_report.ts"; | |
| 12 | import { getReturning, setReturning } from "../../settings/returning.ts"; | |
| 13 | import { notFoundPage } from "../chrome.ts"; | |
| 14 | import { field, readForm } from "../form.ts"; | |
| 15 | import { html, type Html } from "../html.ts"; | |
| 16 | import { flashTo } from "../layout.ts"; | |
| 17 | import { TRAFFIC_TONES } from "../studio/kpi_row.ts"; | |
| 18 | import { btn, row } from "../ui.ts"; | |
| 19 | ||
| 20 | const W = 600; | |
| 21 | const H = 72; | |
| 22 | ||
| 23 | /** The chart's tones are the Traffic chart's own: the lightest for new, the darkest for returning. */ | |
| 24 | const toneOf = (cls: "people" | "deep"): string => TRAFFIC_TONES.find(([c]) => c === cls)![1]; | |
| 25 | ||
| 26 | /** Two series per day, returning over new; a visitor without a cookie counts in the series it belongs to. */ | |
| 27 | function bars(perDay: readonly DayCount[]): Html { | |
| 28 | const back = (d: DayCount): number => d.returningCookie + d.returningProbable; | |
| 29 | const fresh = (d: DayCount): number => d.newCookie + d.newProbable; | |
| 30 | const peak = Math.max(1, ...perDay.map((d) => back(d) + fresh(d))); | |
| 31 | const step = W / Math.max(1, perDay.length); | |
| 32 | const rects: Html[] = []; | |
| 33 | perDay.forEach((d, i) => { | |
| 34 | let y = H; | |
| 35 | for (const [n, tone] of [ | |
| 36 | [back(d), toneOf("deep")], | |
| 37 | [fresh(d), toneOf("people")], | |
| 38 | ] as const) { | |
| 39 | const h = (n / peak) * (H - 2); | |
| 40 | if (h === 0) continue; | |
| 41 | y -= h; | |
| 42 | rects.push(html`<rect x="${(i * step + 0.5).toFixed(1)}" y="${y.toFixed(1)}" width="${Math.max(1, step - 1).toFixed(1)}" height="${h.toFixed(1)}" fill="currentColor" fill-opacity="${tone}"></rect>`); | |
| 43 | } | |
| 44 | }); | |
| 45 | return html`<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" role="img" aria-label="Visitors per day, returning and new">${rects}</svg>`; | |
| 46 | } | |
| 47 | ||
| 48 | const GUESSED = html` <span class="muted small">guessed</span>`; | |
| 49 | ||
| 50 | function returningRow(v: Visitor): Html { | |
| 51 | return row({ | |
| 52 | title: html`<span class="mono">${v.id}</span>${v.probable ? GUESSED : null}`, | |
| 53 | meta: html`first came from ${v.firstRef}`, | |
| 54 | data: { visitor: v.id }, | |
| 55 | facts: [ | |
| 56 | { label: "Visits", value: String(v.visits) }, | |
| 57 | { label: "First seen", value: v.firstSeen }, | |
| 58 | { label: "Last seen", value: v.lastSeen }, | |
| 59 | { label: "Got as far as", value: v.step }, | |
| 60 | ], | |
| 61 | }); | |
| 62 | } | |
| 63 | ||
| 64 | function led(l: Led): Html { | |
| 65 | return row({ | |
| 66 | title: html`<span class="mono">${l.forge}</span>${l.probable ? GUESSED : null}`, | |
| 67 | meta: html`${l.step}`, | |
| 68 | data: { forge: l.forge }, | |
| 69 | facts: [ | |
| 70 | { label: "First came from", value: l.firstRef }, | |
| 71 | { label: "First seen", value: l.firstSeen }, | |
| 72 | ...l.visits.map((v, i) => ({ label: `Visit ${i + 1}, ${v.day}`, value: v.pages.join(" → ") })), | |
| 73 | ], | |
| 74 | }); | |
| 75 | } | |
| 76 | ||
| 77 | /** The forges a visitor started, and the visits that led to each. */ | |
| 78 | export function ledBlock(ctx: Ctx): Html { | |
| 79 | const forges = ledReport(ctx.app.db); | |
| 80 | return html`<section class="settings-block"> | |
| 81 | <h2>What led to a forge</h2> | |
| 82 | ${forges.length === 0 | |
| 83 | ? html`<p class="muted small">No forge has been linked to a visitor yet. Forges started from a plan or a code are; a Stripe checkout is not yet.</p>` | |
| 84 | : html`<ul class="list setting-list">${forges.map(led)}</ul>`} | |
| 85 | </section>`; | |
| 86 | } | |
| 87 | ||
| 88 | /** The whole block for Settings → Traffic. */ | |
| 89 | export function visitorsBlock(ctx: Ctx, now: number): Html { | |
| 90 | const on = getReturning(ctx.app.db); | |
| 91 | const r = on ? visitorsReport(ctx.app.db, daysOf(ctx.url.searchParams.get("days")), now) : undefined; | |
| 92 | return html`<section class="settings-block" id="returning"> | |
| 93 | <h2>Who comes back</h2> | |
| 94 | <form class="row-fields" method="post" action="/settings/traffic/returning"> | |
| 95 | <input type="hidden" name="returning" value="${on ? "off" : "on"}" /> | |
| 96 | <span class="small">${on ? "Keeping a visitor id for this forge's visitors." : "Off: no visitor id is kept for this forge's visitors."}</span> | |
| 97 | ${btn(on ? "Turn off" : "Turn on", { size: "sm", label: on ? "Turn off returning visitors" : "Turn on returning visitors" })} | |
| 98 | </form> | |
| 99 | ${r === undefined | |
| 100 | ? null | |
| 101 | : html`<p class="small muted">${r.newVisitors} new and ${r.returningVisitors} returning visitors in the last ${r.days} days; the darker bars are the ones who came back.</p> | |
| 102 | <div class="muted">${bars(r.perDay)}</div> | |
| 103 | ${r.returning.length === 0 | |
| 104 | ? html`<p class="muted small" data-empty>No visitor has come back in the window.</p>` | |
| 105 | : html`<ul class="list setting-list">${r.returning.map(returningRow)}</ul>`} | |
| 106 | <p class="small muted">A visitor with no cookie is guessed from the browser, system, language and network, and marked guessed. <a href="/privacy">What is kept</a>.</p>`} | |
| 107 | </section>`; | |
| 108 | } | |
| 109 | ||
| 110 | /** POST /settings/traffic/returning: an admin turns the setting on or off. */ | |
| 111 | export async function returningSet(ctx: Ctx): Promise<Response> { | |
| 112 | if (ctx.auth.user?.isAdmin !== true || ctx.auth.user.kind === "agent") return notFoundPage(ctx); | |
| 113 | const form = await readForm(ctx, "admin"); | |
| 114 | if (form instanceof Response) return form; | |
| 115 | const value = field(form, "returning"); | |
| 116 | if (value !== "on" && value !== "off") return flashTo(ctx, "/settings/traffic", { tone: "danger", message: "Returning visitors are on or off." }); | |
| 117 | setReturning(ctx.app.db, value === "on", ctx.auth.user.id); | |
| 118 | return flashTo(ctx, "/settings/traffic", { message: value === "on" ? "Returning visitors are on." : "Returning visitors are off." }); | |
| 119 | } |
test/cookie-flags.test.ts +4
| @@ -49,6 +49,9 @@ const UNREACHED: Record<string, { sites: number; why: string }> = { | ||
| 49 | 49 | "src/web/pages/start_claim.ts": { sites: 1, why: "the claim of an agent's start needs a one-time token; same sessionCookie() as the walked doors; covered by test/start.test.ts" }, |
| 50 | 50 | "src/web/pages/signup-passkey.ts": { sites: 2, why: "sign-up by passkey needs an invite; same sessionCookie() as the walked doors" }, |
| 51 | 51 | "src/web/pages/map.ts": { sites: 1, why: "the map's last-looked cookie holds two timestamps, HttpOnly and Lax, and grants nothing; covered by test/roadmap.test.ts (name, HttpOnly, SameSite, no Secure over http)" }, |
| 52 | "src/http/measure.ts": { sites: 1, why: "the page-view beacon answers with the visitor cookie line from src/http/visitor.ts; HttpOnly, Lax, Secure over https from cookieLine; covered by test/visitors.test.ts" }, | |
| 53 | "src/http/visitor.ts": { sites: 1, why: "the doc comment's mention of the Set-Cookie line it returns; covered by test/visitors.test.ts" }, | |
| 54 | "src/perf/visitor_id.ts": { sites: 1, why: "cookieLine itself, the Set-Cookie line for forge_visitor: HttpOnly, SameSite=Lax, Secure over https; covered by test/visitors.test.ts" }, | |
| 52 | 55 | "src/web/gift_cookie.ts": { sites: 1, why: "the gift code rides an HttpOnly, Lax cookie (Secure over https) and grants nothing by itself; covered by the gift code tests" }, |
| 53 | 56 | "src/web/pages/new-forge.ts": { sites: 1, why: "appends dropGift(), the gift cookie's clearing line from src/web/gift_cookie.ts" }, |
| 54 | 57 | "src/web/pages/everyday.ts": { sites: 2, why: "the admin/everyday switch needs a paired account; same sessionCookie() as the walked doors" }, |
| @@ -234,6 +237,7 @@ const BUILDERS: Record<string, string> = { | ||
| 234 | 237 | "cookie(": "src/apps/scaffold.ts, the scaffolded app's own `cookie`", |
| 235 | 238 | clear: "src/http/rp.ts, `cookieHeader` with Max-Age 0", |
| 236 | 239 | value: "src/apps/scaffold.ts, `redirectTo`'s cookies, each from `cookie`", |
| 240 | cookie: "src/http/measure.ts, the line `trackVisitor` returns, built by `cookieLine` in src/perf/visitor_id.ts", | |
| 237 | 241 | "dropGift(": "src/web/gift_cookie.ts, the gift cookie cleared with Max-Age 0", |
| 238 | 242 | "o.cookie": "src/web/layout.ts, a page's cookie (the theme literal in src/web/pages/site.ts)", |
| 239 | 243 | }; |
test/data-browser.test.ts +1
| @@ -26,6 +26,7 @@ const AUDITED_CLEAR: string[] = [ | ||
| 26 | 26 | "app_feature_setting.value", |
| 27 | 27 | "comment.body", |
| 28 | 28 | "degraded_span.sentence", |
| 29 | "efficiency_sample.value", | |
| 29 | 30 | "feed_chip.value", |
| 30 | 31 | "fleet_operation.value", |
| 31 | 32 | "gift_redeem.code_id", |
test/forge-looks.test.ts +1
| @@ -73,6 +73,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 73 | 73 | "/displays": "a list of displays behind sign-in; its slot is not pictured; covered by test/displays-list.test.ts", |
| 74 | 74 | "/search": "a search box; its slot went to the preview seed's /next as its owner (#8866)", |
| 75 | 75 | "/contribute": "a static page about how to help; its slot went to the preview seed's /bets as its owner (#8866)", |
| 76 | "/privacy": "three static lines on what the forge keeps about a visitor (#11265); covered by test/visitors.test.ts", | |
| 76 | 77 | "/migrate": "an import form, nothing to picture until a job runs; the look slot went to /repos", |
| 77 | 78 | }; |
| 78 | 79 |
test/palette-roster.test.ts +1
| @@ -122,6 +122,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 122 | 122 | ["crawlers", "Crawlers", "their share of answering time, and who was refused"], |
| 123 | 123 | ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"], |
| 124 | 124 | ["traffic", "Traffic", "who is using the forge's answering time, and the top readers"], |
| 125 | ["returning-visitors", "Who comes back", "new and returning visitors, and the setting that keeps them"], | |
| 125 | 126 | ["api", "API", "the forge's endpoints; run a GET"], |
| 126 | 127 | ["docs", "Docs", "the forge's documentation; Enter reads one"], |
| 127 | 128 | ["changelog", "Changelog", "the last 30 releases and their pulls"], |
test/visitors.test.ts added +274
| @@ -0,0 +1,274 @@ | ||
| 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 { browserOf, cookieId, mintId, osOf, prefixOf, probableId } from "../src/perf/visitor_id.ts"; | |
| 8 | import { linkVisitor, noteVisitor, purgeVisitors, KEEP_MS } from "../src/perf/visitor_store.ts"; | |
| 9 | import { visitorsReport, probeVisitors } from "../src/perf/visitor_report.ts"; | |
| 10 | import { getReturning, setReturning } from "../src/settings/returning.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { linkRequestVisitor } from "../src/perf/visitor_link.ts"; | |
| 14 | import { loadKey } from "../src/kernel/secret.ts"; | |
| 15 | import { returningLines } from "../src/tui/screens/returning.ts"; | |
| 16 | import type { Session } from "../src/tui/session.ts"; | |
| 17 | ||
| 18 | let dir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let base: string; | |
| 22 | let key: Buffer; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dir = await mkdtemp(join(tmpdir(), "visitors-")); | |
| 26 | app = createApp({ dataDir: dir, port: 0 }); | |
| 27 | servers = await serve(app); | |
| 28 | base = `http://127.0.0.1:${servers.port}`; | |
| 29 | key = loadKey(dir); | |
| 30 | }); | |
| 31 | afterAll(async () => { | |
| 32 | await servers.stop(); | |
| 33 | await rm(dir, { recursive: true, force: true }); | |
| 34 | }); | |
| 35 | ||
| 36 | const CHROME = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; | |
| 37 | const DAY = 86_400_000; | |
| 38 | const T0 = Date.parse("2026-11-01T10:00:00Z"); | |
| 39 | const beacon = (headers: Record<string, string> = {}) => | |
| 40 | fetch(`${base}/_measure`, { | |
| 41 | method: "POST", | |
| 42 | headers: { origin: base, "user-agent": CHROME, "accept-language": "en-GB,en;q=0.8", ...headers }, | |
| 43 | body: JSON.stringify({ path: "/", ttfb: 40, load: 300, lcp: 250, inp: 80, cls: 0.02 }), | |
| 44 | }); | |
| 45 | const count = (table = "visitor") => (app.db.query(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n; | |
| 46 | const wipe = () => { | |
| 47 | app.db.exec("DELETE FROM visitor; DELETE FROM visitor_visit"); | |
| 48 | }; | |
| 49 | ||
| 50 | describe("a returning visitor", () => { | |
| 51 | test("every forge reads the setting off until its owner turns it on, and an owner can change it", () => { | |
| 52 | expect(getReturning(app.db)).toBe(false); | |
| 53 | expect(app.db.query("SELECT COUNT(*) AS n FROM forge_setting WHERE key = 'traffic.returning'").get()).toEqual({ n: 0 }); | |
| 54 | setReturning(app.db, true, "u"); | |
| 55 | expect(getReturning(app.db)).toBe(true); | |
| 56 | app.db.exec("DELETE FROM forge_setting WHERE key = 'traffic.returning'"); | |
| 57 | expect(getReturning(app.db)).toBe(false); | |
| 58 | setReturning(app.db, false, "u"); | |
| 59 | expect(getReturning(app.db)).toBe(false); | |
| 60 | }); | |
| 61 | ||
| 62 | test("with the setting off nothing is stored and no cookie is set", async () => { | |
| 63 | wipe(); | |
| 64 | const res = await beacon(); | |
| 65 | expect(res.status).toBe(204); | |
| 66 | expect(res.headers.get("set-cookie")).toBeNull(); | |
| 67 | expect(count()).toBe(0); | |
| 68 | }); | |
| 69 | ||
| 70 | test("the first view sets a first-party cookie with every attribute, and the same cookie on two days is one returning visitor with two visits", async () => { | |
| 71 | setReturning(app.db, true, "u"); | |
| 72 | wipe(); | |
| 73 | const first = await beacon(); | |
| 74 | const line = first.headers.get("set-cookie")!; | |
| 75 | expect(line).toMatch(/^forge_visitor=c[0-9a-f]{32}\.[0-9a-f]{32}; /); | |
| 76 | for (const part of ["Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${396 * 86_400}`]) expect(line).toContain(part); | |
| 77 | expect(line).not.toContain("Domain"); | |
| 78 | const id = cookieId(line, key)!; | |
| 79 | expect(id).toBeDefined(); | |
| 80 | // The same cookie, on two different days. | |
| 81 | noteVisitor(app.db, { id, kind: "cookie", path: "/", ref: "news.example", step: 1, at: T0 }); | |
| 82 | noteVisitor(app.db, { id, kind: "cookie", path: "/plans?x=1", ref: "", step: 2, at: T0 + 3 * DAY }); | |
| 83 | const r = visitorsReport(app.db, 30, T0 + 4 * DAY); | |
| 84 | const mine = r.returning.find((v) => v.id === id.slice(0, 9))!; | |
| 85 | expect(mine).toMatchObject({ visits: 2, daysSeen: 2, probable: false, firstRef: "news.example", step: "clicked a plan" }); | |
| 86 | expect(r.returningVisitors).toBe(1); | |
| 87 | const pages = app.db.query("SELECT pages FROM visitor_visit WHERE visitor = ? ORDER BY id").all(id) as { pages: string }[]; | |
| 88 | expect(pages.map((p) => JSON.parse(p.pages))).toEqual([["/"], ["/plans"]]); | |
| 89 | expect(probeVisitors(app.db, 30, T0 + 4 * DAY, r).agrees).toBe(true); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a cookie-less repeat from the same network and browser is probable, and is not merged with a cookie id", async () => { | |
| 93 | wipe(); | |
| 94 | await beacon(); | |
| 95 | await beacon(); | |
| 96 | const rows = app.db.query("SELECT id, kind, visits FROM visitor").all() as { id: string; kind: string; visits: number }[]; | |
| 97 | expect(rows).toHaveLength(1); | |
| 98 | expect(rows[0]!.kind).toBe("probable"); | |
| 99 | expect(rows[0]!.id.startsWith("p")).toBe(true); | |
| 100 | // Two views within 30 minutes are one visit; the probable row still reads "probable". | |
| 101 | const r = visitorsReport(app.db, 30, Date.now()); | |
| 102 | expect(r.newVisitors).toBe(1); | |
| 103 | noteVisitor(app.db, { id: rows[0]!.id, kind: "probable", path: "/", ref: "", step: 0, at: Date.now() + 2 * DAY }); | |
| 104 | expect(visitorsReport(app.db, 30, Date.now() + 2 * DAY).returning[0]).toMatchObject({ probable: true, visits: 2 }); | |
| 105 | // A cookie arriving is a different visitor. | |
| 106 | await beacon({ cookie: `forge_visitor=${mintId(key).value}` }); | |
| 107 | expect(count()).toBe(2); | |
| 108 | }); | |
| 109 | ||
| 110 | test("only a cookie this forge signed is accepted: invented ids do not make rows", async () => { | |
| 111 | setReturning(app.db, true, "u"); | |
| 112 | wipe(); | |
| 113 | const real = mintId(key); | |
| 114 | expect(cookieId(`forge_visitor=${real.value}`, key)).toBe(real.id); | |
| 115 | expect(cookieId(`forge_visitor=${real.value}`, Buffer.from("another forge's key"))).toBeUndefined(); | |
| 116 | expect(cookieId(`forge_visitor=${real.id}.${"0".repeat(32)}`, key)).toBeUndefined(); | |
| 117 | expect(cookieId(`forge_visitor=${real.id}`, key)).toBeUndefined(); | |
| 118 | for (let i = 0; i < 20; i++) { | |
| 119 | const invented = `c${i.toString(16).padStart(32, "0")}.${"a".repeat(32)}`; | |
| 120 | const res = await beacon({ cookie: `forge_visitor=${invented}` }); | |
| 121 | expect(res.status).toBe(204); | |
| 122 | } | |
| 123 | // All twenty are one cookie-less probable visitor; none is a cookie row, and a fresh cookie is offered. | |
| 124 | const rows = app.db.query("SELECT kind FROM visitor").all() as { kind: string }[]; | |
| 125 | expect(rows).toEqual([{ kind: "probable" }]); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a day takes at most the cap of new visitors; known visitors still count", () => { | |
| 129 | wipe(); | |
| 130 | const at = T0; | |
| 131 | const first = mintId(key).id; | |
| 132 | expect(noteVisitor(app.db, { id: first, kind: "cookie", path: "/", ref: "", step: 0, at }, 2)).toBe(true); | |
| 133 | expect(noteVisitor(app.db, { id: mintId(key).id, kind: "cookie", path: "/", ref: "", step: 0, at }, 2)).toBe(true); | |
| 134 | expect(noteVisitor(app.db, { id: mintId(key).id, kind: "cookie", path: "/", ref: "", step: 0, at }, 2)).toBe(false); | |
| 135 | expect(count()).toBe(2); | |
| 136 | expect(noteVisitor(app.db, { id: first, kind: "cookie", path: "/x", ref: "", step: 0, at: at + 1000 }, 2)).toBe(true); | |
| 137 | // The next day has its own allowance. | |
| 138 | expect(noteVisitor(app.db, { id: mintId(key).id, kind: "cookie", path: "/", ref: "", step: 0, at: at + DAY }, 2)).toBe(true); | |
| 139 | expect(count()).toBe(3); | |
| 140 | }); | |
| 141 | ||
| 142 | test("the terminal shows the same figures to an admin only, and its switch is the one the page reads", () => { | |
| 143 | wipe(); | |
| 144 | setReturning(app.db, false, "u"); | |
| 145 | const admin = { db: app.db, user: { id: "u", isAdmin: true, kind: "human" } } as unknown as Session; | |
| 146 | const other = { db: app.db, user: { id: "v", isAdmin: false, kind: "human" } } as unknown as Session; | |
| 147 | expect(returningLines(admin)[0]!.title).toContain("Off"); | |
| 148 | setReturning(app.db, true, "u"); | |
| 149 | const { id } = mintId(key); | |
| 150 | noteVisitor(app.db, { id, kind: "cookie", path: "/", ref: "news.example", step: 1, at: Date.now() - DAY }); | |
| 151 | noteVisitor(app.db, { id, kind: "cookie", path: "/", ref: "", step: 1, at: Date.now() }); | |
| 152 | const lines = returningLines(admin); | |
| 153 | expect(lines[0]!.title).toContain("1 returning"); | |
| 154 | expect(lines.some((l) => l.title.startsWith(id.slice(0, 9)))).toBe(true); | |
| 155 | expect(returningLines(other)).toEqual([]); | |
| 156 | }); | |
| 157 | ||
| 158 | test("a different browser or network is a different probable visitor, and a forged cookie is ignored", () => { | |
| 159 | const h = (ua: string) => new Headers({ "user-agent": ua, "accept-language": "en" }); | |
| 160 | const a = probableId("s", h(CHROME), "203.0.113.5"); | |
| 161 | expect(probableId("s", h(CHROME), "203.0.113.200")).toBe(a); | |
| 162 | expect(probableId("s", h(CHROME), "203.0.114.5")).not.toBe(a); | |
| 163 | expect(probableId("s", h(CHROME.replace("Chrome/120", "Chrome/121")), "203.0.113.5")).not.toBe(a); | |
| 164 | expect(probableId("t", h(CHROME), "203.0.113.5")).not.toBe(a); | |
| 165 | expect(cookieId("forge_visitor=../../etc", key)).toBeUndefined(); | |
| 166 | expect(prefixOf("203.0.113.5")).toBe("203.0.113/24"); | |
| 167 | expect(prefixOf("2001:db8:1:2:3:4:5:6")).toBe("2001:db8:1/48"); | |
| 168 | expect(browserOf(CHROME)).toBe("chrome/120"); | |
| 169 | expect(osOf(CHROME)).toBe("windows"); | |
| 170 | }); | |
| 171 | ||
| 172 | test("Sec-GPC and DNT get no cookie and no row, a scraper gets no id", async () => { | |
| 173 | wipe(); | |
| 174 | for (const headers of [{ "sec-gpc": "1" }, { dnt: "1" }]) { | |
| 175 | const res = await beacon(headers); | |
| 176 | expect(res.status).toBe(204); | |
| 177 | expect(res.headers.get("set-cookie")).toBeNull(); | |
| 178 | } | |
| 179 | const bot = await beacon({ "user-agent": "Googlebot/2.1 (+http://www.google.com/bot.html)" }); | |
| 180 | expect(bot.headers.get("set-cookie")).toBeNull(); | |
| 181 | const none = await beacon({ "user-agent": "" }); | |
| 182 | expect(none.headers.get("set-cookie")).toBeNull(); | |
| 183 | expect(count()).toBe(0); | |
| 184 | expect(count("visitor_visit")).toBe(0); | |
| 185 | }); | |
| 186 | ||
| 187 | test("the visitor who starts a forge is linked to it, with the visits that led there", () => { | |
| 188 | wipe(); | |
| 189 | const { id, value } = mintId(key); | |
| 190 | noteVisitor(app.db, { id, kind: "cookie", path: "/", ref: "news.example", step: 1, at: Date.now() - 6 * DAY }); | |
| 191 | noteVisitor(app.db, { id, kind: "cookie", path: "/agents", ref: "", step: 1, at: Date.now() - 3 * DAY }); | |
| 192 | noteVisitor(app.db, { id, kind: "cookie", path: "/spawn", ref: "", step: 2, at: Date.now() }); | |
| 193 | linkRequestVisitor(app, new Request(base, { headers: { cookie: `forge_visitor=${value}` } }), "acme", "started"); | |
| 194 | expect(linkVisitor(app.db, id, "other", "paid", Date.now())).toBe(false); | |
| 195 | const led = visitorsReport(app.db, 30, Date.now()).led[0]!; | |
| 196 | expect(led).toMatchObject({ forge: "acme", step: "started a forge", firstRef: "news.example" }); | |
| 197 | expect(led.visits).toHaveLength(3); | |
| 198 | linkRequestVisitor(app, new Request(base, { headers: { cookie: `forge_visitor=${value}`, "sec-gpc": "1" } }), "acme", "paid"); | |
| 199 | expect(visitorsReport(app.db, 30, Date.now()).led[0]!.step).toBe("started a forge"); | |
| 200 | linkRequestVisitor(app, new Request(base, { headers: { cookie: `forge_visitor=${value}` } }), "acme", "paid"); | |
| 201 | expect(visitorsReport(app.db, 30, Date.now()).led[0]!.step).toBe("paid"); | |
| 202 | }); | |
| 203 | ||
| 204 | test("rows past 13 months are removed, newer ones kept", () => { | |
| 205 | wipe(); | |
| 206 | const now = Date.now(); | |
| 207 | noteVisitor(app.db, { id: mintId(key).id, kind: "cookie", path: "/", ref: "", step: 0, at: now - KEEP_MS - DAY }); | |
| 208 | noteVisitor(app.db, { id: mintId(key).id, kind: "cookie", path: "/", ref: "", step: 0, at: now - 30 * DAY }); | |
| 209 | expect(purgeVisitors(app.db, now)).toBe(1); | |
| 210 | expect(count()).toBe(1); | |
| 211 | expect(count("visitor_visit")).toBe(1); | |
| 212 | }); | |
| 213 | ||
| 214 | test("no raw address or full user agent is in the store", async () => { | |
| 215 | wipe(); | |
| 216 | await fetch(`${base}/_measure`, { | |
| 217 | method: "POST", | |
| 218 | headers: { origin: base, "user-agent": `${CHROME} SecretMarker`, "x-forwarded-for": "198.51.100.77" }, | |
| 219 | body: JSON.stringify({ path: "/", ttfb: 40, load: 300, lcp: 250, inp: 80, cls: 0.02 }), | |
| 220 | }); | |
| 221 | expect(count()).toBe(1); | |
| 222 | const dump = JSON.stringify([ | |
| 223 | app.db.query("SELECT * FROM visitor").all(), | |
| 224 | app.db.query("SELECT * FROM visitor_visit").all(), | |
| 225 | app.db.query("SELECT * FROM visitor_salt").all(), | |
| 226 | ]); | |
| 227 | for (const needle of ["SecretMarker", "Mozilla", "198.51.100", "127.0.0.1", "203.0.113"]) expect(dump).not.toContain(needle); | |
| 228 | }); | |
| 229 | ||
| 230 | test("the privacy page says what is kept, and the visitors API is for admins only", async () => { | |
| 231 | const page = await (await fetch(`${base}/privacy`)).text(); | |
| 232 | expect(page).toContain("13 months"); | |
| 233 | expect(page).toContain("never leaves this forge"); | |
| 234 | expect([401, 404]).toContain((await fetch(`${base}/api/v1/admin/traffic/visitors?days=30`)).status); | |
| 235 | }); | |
| 236 | ||
| 237 | test("an admin reads the same figures from the API and the Traffic page, and flips the setting from it", async () => { | |
| 238 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "ada", email: "[email protected]", password: "correct-horse-battery", isAdmin: true }); | |
| 239 | if (!made.ok) throw new Error(made.error); | |
| 240 | const cookie = `forge_session=${createSession(app.db, made.value.id).token}`; | |
| 241 | wipe(); | |
| 242 | setReturning(app.db, true, "u"); | |
| 243 | const { id } = mintId(key); | |
| 244 | noteVisitor(app.db, { id, kind: "cookie", path: "/", ref: "news.example", step: 1, at: Date.now() - DAY }); | |
| 245 | noteVisitor(app.db, { id, kind: "cookie", path: "/", ref: "", step: 1, at: Date.now() }); | |
| 246 | const api = (await (await fetch(`${base}/api/v1/admin/traffic/visitors?days=30`, { headers: { cookie } })).json()) as { | |
| 247 | returning: { id: string; visits: number }[]; | |
| 248 | returning_visitors: number; | |
| 249 | probe: { agrees: boolean }; | |
| 250 | }; | |
| 251 | expect(api.returning_visitors).toBe(1); | |
| 252 | expect(api.returning[0]).toMatchObject({ id: id.slice(0, 9), visits: 2 }); | |
| 253 | expect(api.probe.agrees).toBe(true); | |
| 254 | const page = await (await fetch(`${base}/settings/traffic`, { headers: { cookie } })).text(); | |
| 255 | expect(page).toContain(id.slice(0, 9)); | |
| 256 | expect(page).toContain("news.example"); | |
| 257 | const off = await fetch(`${base}/settings/traffic/returning`, { | |
| 258 | method: "POST", | |
| 259 | redirect: "manual", | |
| 260 | headers: { cookie, origin: base, "content-type": "application/x-www-form-urlencoded" }, | |
| 261 | body: "returning=off", | |
| 262 | }); | |
| 263 | expect(off.status).toBe(303); | |
| 264 | expect(getReturning(app.db)).toBe(false); | |
| 265 | const cross = await fetch(`${base}/settings/traffic/returning`, { | |
| 266 | method: "POST", | |
| 267 | redirect: "manual", | |
| 268 | headers: { cookie, origin: "https://evil.example", "content-type": "application/x-www-form-urlencoded" }, | |
| 269 | body: "returning=on", | |
| 270 | }); | |
| 271 | expect(cross.status).not.toBe(303); | |
| 272 | expect(getReturning(app.db)).toBe(false); | |
| 273 | }); | |
| 274 | }); |
new above
offline