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

The traffic view knows a returning visitor: new versus returning, how many vi… #11267

Merged opened this in answer to

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

Merged

torque/forge:torque/11265-the-traffic-view-knows-a-returning-visit → main 9 commits fork

  • Merged. torque/11265-the-traffic-view-knows-a-returning-visit was merged into main as cde9dfe.

Review

Sign in to review this change.
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`,
9393 - `/forges/new` — `pages/new-forge.ts` · send a new forge · none · colour 1.
9494 - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1.
9595 - `/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.
9697 - `/first-admin` — `pages/first-admin.ts` · the first person becomes admin · none · none seen.
9798 - `/fleet/graph` — `pages/fleet-graph.ts` · the fleet as a graph · `/family`, `/forges` · none seen.
9899 - `/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";
5555 export { route as getApiV1AdminSubscriptions } from "./getApiV1AdminSubscriptions.ts";
5656 export { route as getApiV1AdminTeams } from "./getApiV1AdminTeams.ts";
5757 export { route as getApiV1AdminTrafficProbe } from "./getApiV1AdminTrafficProbe.ts";
58 export { route as getApiV1AdminTrafficVisitors } from "./getApiV1AdminTrafficVisitors.ts";
5859 export { route as getApiV1Agents } from "./getApiV1Agents.ts";
5960 export { route as getApiV1AgentsUsername } from "./getApiV1AgentsUsername.ts";
6061 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[] = [
3535 "crew",
3636 "agent",
3737 "agents",
38 "privacy",
3839 "bot",
3940 "steward",
4041 "anonymous",
src/http/api/admin.ts +40
@@ -10,6 +10,8 @@ import { crawlReport } from "../../perf/crawlers.ts";
1010 import { getAppById } from "../../core/apps.ts";
1111 import { FUNNEL_SCOPE, daysOf, funnelReport } from "../../perf/funnel.ts";
1212 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";
1315 import { kpiProbe } from "../../web/studio/kpi_probe.ts";
1416 import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts";
1517 import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts";
@@ -179,6 +181,44 @@ export function adminRoutes(router: Router<Handler>): void {
179181 return json(only === null ? probed : { ...probed, numbers: probed.numbers.filter((n) => n.id === only) });
180182 });
181183
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
182222 // The forge's capacity (`core/capacity.ts`): read and changed live by an
183223 // admin, never by an agent; anyone else is told the door is not there.
184224 router.add("GET", "/api/v1/admin/capacity", (ctx) => {
src/http/measure.ts +5 −2
@@ -17,6 +17,7 @@ import { readBounded } from "./limits.ts";
1717 import { clientAddress, clientBucket } from "./peer.ts";
1818 import { originOf } from "../perf/sources.ts";
1919 import { noteView } from "../perf/views_store.ts";
20 import { trackVisitor } from "./visitor.ts";
2021
2122 const PER_CLIENT = 30;
2223 const PER_FORGE = 1200;
@@ -65,13 +66,15 @@ export async function measureSubmit(ctx: Ctx, routeOf: RouteOf): Promise<Respons
6566 clientAddress(request),
6667 request.headers.get("user-agent") ?? "",
6768 );
69 const origin = originOf(report.ref, report.q, ctx.url.host, report.out);
6870 noteView(app.db, {
6971 scope: "forge",
7072 // The route pattern, never the path: a path can name a private repository.
7173 path: route,
72 origin: originOf(report.ref, report.q, ctx.url.host, report.out),
74 origin,
7375 client: clientAddress(request),
7476 agent: request.headers.get("user-agent") ?? "",
7577 });
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 });
7780 }
src/http/server.ts +2
@@ -17,6 +17,7 @@ import {
1717 import { secureFor } from "./cookie_secure.ts";
1818 import { refusedByDatabase, shownRefusal } from "./refusal.ts";
1919 import { NOT_A_MEMBER } from "../core/roles.ts";
20 import { privacyPage } from "../web/pages/privacy.ts";
2021 import { startClaimAccountSubmit, startClaimPage, startClaimSubmit } from "../web/pages/start_claim.ts";
2122 import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
2223 import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts";
@@ -939,6 +940,7 @@ export function buildRouter(): Router<Handler> {
939940 router.add("GET", "/audit", auditPage);
940941 router.add("GET", "/releases", releasesPage);
941942 // The forge's own documentation: a redirect built from configuration only.
943 router.add("GET", "/privacy", privacyPage);
942944 router.add("GET", "/docs", docsFolder);
943945 router.add("GET", "/docs/:file", docsFile);
944946 router.add("GET", "/changelog", changelogPage);
src/http/settings_routes.ts +2
@@ -26,6 +26,7 @@ import { spendPage } from "../web/pages/spend-tab.ts";
2626 import { securityControlsPage } from "../web/pages/security_controls.ts";
2727 import { securityPage, securityShieldSet } from "../web/pages/security.ts";
2828 import { trafficPage } from "../web/pages/traffic.ts";
29 import { returningSet } from "../web/pages/visitors.ts";
2930 import { sourcesPage } from "../web/pages/sources.ts";
3031 import { requestAct } from "../web/requests_card.ts";
3132 import { captureStart, captureStop, networksImport, scrapesExport, scrapesPage } from "../web/pages/scrapes.ts";
@@ -235,6 +236,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
235236 router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns));
236237 router.add("GET", "/settings/spend", spendPage);
237238 router.add("GET", "/settings/traffic", trafficPage);
239 router.add("POST", "/settings/traffic/returning", returningSet);
238240 router.add("GET", "/settings/traffic/sources", sourcesPage);
239241 router.add("GET", "/settings/traffic/scrapes", scrapesPage);
240242 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";
55 import { duty as ruleWatcher } from "./duties/rule_watcher.ts";
66 import { duty as efficiencyWatcher } from "./duties/efficiency_watcher.ts";
77 import { duty as flowKeeper } from "./duties/flow_keeper.ts";
8 import { duty as visitorRetention } from "./duties/visitor_retention.ts";
89
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_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 @@
66 * the caller says `false` — returns the plan and changes nothing.
77 */
88
9 import { linkRequestVisitor } from "../perf/visitor_link.ts";
910 import { startWatch, probeOf, type Probe } from "./live.ts";
1011 import { firstProof, reachWords, settleChild } from "./reach.ts";
1112 import { reissueClaim, spawnForge, type SpawnError } from "../actions/spawn.ts";
@@ -364,6 +365,8 @@ export async function spawnDoor(
364365 }
365366 if (!made.ok) return spawnWords(made.error);
366367 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");
367370 // Its apps come up now, so the first visit after a claim finds them awake.
368371 if (!made.value.existed) {
369372 void bootHostedChild(ctx.app, made.value.name);
src/tui/screens/entries.ts +2
@@ -79,6 +79,7 @@ import { entry as scorecard } from "./scorecard.ts";
7979 import { entry as weekcard } from "./weekcard.ts";
8080 import { entry as scrapes } from "./scrapes.ts";
8181 import { entry as trafficScreenEntry } from "./traffic.ts";
82 import { entry as returningEntry } from "./returning.ts";
8283 import { entry as securityControls } from "./controls.ts";
8384 import { entry as secrets } from "./secrets.ts";
8485 import { entry as sentinel } from "./sentinel.ts";
@@ -151,6 +152,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
151152 flowsAll,
152153 scrapes,
153154 trafficScreenEntry,
155 returningEntry,
154156 teams,
155157 orgTeams,
156158 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 @@
55 * Anyone else gets the not-found page.
66 */
77
8 import { ledBlock } from "./visitors.ts";
89 import { BILLING_WORDS, STRIPE_WORDS, connectStripe, disconnectStripe, grantPlan, readBilling, saveSigningSecret, type BillingError, type StripeWords } from "../../actions/billing.ts";
910 import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts";
1011 import { publicOrigin } from "../../core/public_origin.ts";
@@ -186,6 +187,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {
186187 ? html`<p class="muted">No forge has been sent from here.</p>`
187188 : html`<ul class="list setting-list">${rows}</ul>`}
188189 </section>
190 ${ledBlock(ctx)}
189191 ${funnelRow(funnelReport(ctx.app.db, 7))}
190192 ${linksSection(ctx, links)}
191193 ${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";
3232 import { TRAFFIC_TONES, trafficColumns } from "../studio/kpi_row.ts";
3333 import { row, seg } from "../ui.ts";
3434 import { accountSections } from "./origins.ts";
35 import { visitorsBlock } from "./visitors.ts";
3536
3637 const CHART_W = 600;
3738 const CHART_H = 96;
@@ -192,7 +193,7 @@ export function trafficPage(ctx: Ctx): Response {
192193 })),
193194 )}
194195 </div>
195 ${body}`,
196 ${body}${visitorsBlock(ctx, now)}`,
196197 "Account settings",
197198 )}`,
198199 });
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/data-browser.test.ts +1
@@ -26,6 +26,7 @@ const AUDITED_CLEAR: string[] = [
2626 "app_feature_setting.value",
2727 "comment.body",
2828 "degraded_span.sentence",
29 "efficiency_sample.value",
2930 "feed_chip.value",
3031 "fleet_operation.value",
3132 "gift_redeem.code_id",
test/forge-looks.test.ts +1
@@ -73,6 +73,7 @@ const NOT_A_PAGE: Record<string, string> = {
7373 "/displays": "a list of displays behind sign-in; its slot is not pictured; covered by test/displays-list.test.ts",
7474 "/search": "a search box; its slot went to the preview seed's /next as its owner (#8866)",
7575 "/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",
7677 "/migrate": "an import form, nothing to picture until a job runs; the look slot went to /repos",
7778 };
7879
test/palette-roster.test.ts +1
@@ -122,6 +122,7 @@ test("a site-admin person is offered the whole roster", () => {
122122 ["crawlers", "Crawlers", "their share of answering time, and who was refused"],
123123 ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"],
124124 ["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"],
125126 ["api", "API", "the forge's endpoints; run a GET"],
126127 ["docs", "Docs", "the forge's documentation; Enter reads one"],
127128 ["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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs