diff --git a/system/public/aesthetic.computer/lib/visit-model.mjs b/system/public/aesthetic.computer/lib/visit-model.mjs index d6c509a866..d849acb282 100644 --- a/system/public/aesthetic.computer/lib/visit-model.mjs +++ b/system/public/aesthetic.computer/lib/visit-model.mjs @@ -1,5 +1,10 @@ // Public property and event allowlists. No wildcard hosts or arbitrary labels. +export const CLIENT_VISIT_PROPERTIES = Object.freeze(["false.work", "danzballet.studio", "regarde.io", "drvkforlife.com"]); export const VISIT_PROPERTIES = Object.freeze({ + "false.work": ["www.false.work"], + "danzballet.studio": ["www.danzballet.studio"], + "regarde.io": ["www.regarde.io"], + "drvkforlife.com": ["www.drvkforlife.com"], "aesthetic.computer": ["www.aesthetic.computer", "p5.aesthetic.computer"], "oskiewar.com": ["www.oskiewar.com", "midi.oskiewar.com"], "nopaint.art": ["www.nopaint.art"], @@ -38,6 +43,16 @@ export const INPUTS = Object.freeze(["pointer", "touch", "keyboard", "scroll", " export const RETENTION_DAYS = 35; export const VISIT_COLLECTION = "network-visits"; +export function visitGroup(property) { + return CLIENT_VISIT_PROPERTIES.includes(property) ? "clients" : "studio"; +} + +export function visitScopeMatch(scope = "studio") { + if (!["studio", "clients", "all"].includes(scope)) throw new Error("Use --scope studio, clients, or all"); + return { property: { $in: Object.keys(VISIT_PROPERTIES).filter(property => + scope === "all" || visitGroup(property) === scope) } }; +} + export function visitProperty(hostname) { const host = String(hostname || "").toLowerCase(); return Object.entries(VISIT_PROPERTIES).find(([name, aliases]) => @@ -93,16 +108,16 @@ export function visitUpdate(visit, now = new Date()) { for (const input of visit.inputs) max[`inputs.${input}`] = true; for (const action of visit.actions) max[`actions.${action}`] = true; return { - $setOnInsert: { property: visit.property, surface: visit.surface, + $setOnInsert: { property: visit.property, group: visitGroup(visit.property), surface: visit.surface, expiresAt: new Date(now.getTime() + RETENTION_DAYS * 86400000) }, $min: { startedAt: now }, $max: { ...max, lastSeenAt: now }, }; } -export function visitReportPipeline(start, end, byPeriod = false) { +export function visitReportPipeline(start, end, byPeriod = false, scope = "studio") { const count = field => ({ $sum: { $cond: [`$${field}`, 1, 0] } }); return [ - { $match: { startedAt: { $gte: start, $lt: end } } }, + { $match: { ...visitScopeMatch(scope), startedAt: { $gte: start, $lt: end } } }, { $group: { _id: { property: "$property", automated: "$automated", ...(byPeriod ? { period: { $floor: { $divide: [{ $subtract: ["$startedAt", start] }, 86400000] } } } diff --git a/system/public/aesthetic.computer/lib/visit-tracker.mjs b/system/public/aesthetic.computer/lib/visit-tracker.mjs index 48b5ad48ff..f21c6fc898 100644 --- a/system/public/aesthetic.computer/lib/visit-tracker.mjs +++ b/system/public/aesthetic.computer/lib/visit-tracker.mjs @@ -121,7 +121,7 @@ export function startVisitTracker(win = window, doc = document) { pad?.axes?.slice(0, 4).some(axis => Math.abs(axis) > 0.5))) interact("gamepad"); } catch { /* unavailable in some embedded browsers */ } }, 1000); - const api = { action, stop() { stopped = true; win.clearInterval(timer); listeners.forEach(remove => remove()); } }; + const api = { action, stop() { stopped = true; win.clearInterval(timer); listeners.forEach(remove => remove()); if (win.acVisits === api) delete win.acVisits; } }; win.acVisits = api; if (visible()) send(); return api; diff --git a/system/public/danzballet.studio/index.html b/system/public/danzballet.studio/index.html index eb323a31b7..445424ac41 100644 --- a/system/public/danzballet.studio/index.html +++ b/system/public/danzballet.studio/index.html @@ -200,6 +200,7 @@ } } + diff --git a/system/public/false.work/index.html b/system/public/false.work/index.html index baa1675e4d..01b8b72b32 100644 --- a/system/public/false.work/index.html +++ b/system/public/false.work/index.html @@ -61,6 +61,7 @@ opacity: 0.8; } +
diff --git a/system/tests/visit-tracking.test.mjs b/system/tests/visit-tracking.test.mjs index ebcc0c3726..5e2fc25284 100644 --- a/system/tests/visit-tracking.test.mjs +++ b/system/tests/visit-tracking.test.mjs @@ -51,3 +51,44 @@ test("collector rejects invalid requests before opening the database", async () { httpMethod: "POST", body: JSON.stringify(snapshot()), headers: { origin: "https://evil.test" } }, ]) assert.ok((await handler(event)).statusCode >= 400); }); + +test("report defaults to studio and client identity cannot be spoofed", async () => { + const { visitScopeMatch, visitGroup, visitReportPipeline, CLIENT_VISIT_PROPERTIES } = await import("../public/aesthetic.computer/lib/visit-model.mjs"); + const studio = visitScopeMatch().property.$in; + for (const property of CLIENT_VISIT_PROPERTIES) { + assert.ok(!studio.includes(property)); + assert.equal(visitGroup(property), "clients"); + const visit = validateVisit({ ...snapshot(), group: "studio" }, `https://${property}`); + assert.equal(visitUpdate(visit).$setOnInsert.group, "clients"); + } + assert.deepEqual(visitScopeMatch("clients").property.$in, [...CLIENT_VISIT_PROPERTIES]); + assert.ok(visitScopeMatch("all").property.$in.includes("jas.life")); + assert.throws(() => visitScopeMatch("typo")); + for (const byPeriod of [false, true]) { + const pipeline = visitReportPipeline(new Date(0), new Date(), byPeriod); + assert.deepEqual(pipeline[0].$match.property.$in, studio); + } + for (const host of ["labs.regarde.io", "draft.regarde.io", "builds.false.work", "xbq1m1-qa.myshopify.com"]) + assert.equal(visitProperty(host), null); +}); + +test("Shopify tracking follows analytics consent, including revocation during loading", async () => { + const { startShopifyVisits } = await import("../public/aesthetic.computer/lib/visit-shopify.mjs"); + let allowed = false, starts = 0, stops = 0, resolve; + const doc = new EventTarget(); + const win = { Shopify: { customerPrivacy: { analyticsProcessingAllowed: () => allowed } } }; + const module = { startVisitTracker() { starts++; win.acVisits = { stop() { stops++; delete win.acVisits; } }; } }; + let pending = new Promise(r => { resolve = r; }); + startShopifyVisits(win, doc, () => pending); + const tick = () => new Promise(r => setImmediate(r)); + assert.equal(win.acVisitTrackingDisabled, true); + allowed = true; doc.dispatchEvent(new Event("visitorConsentCollected")); + allowed = false; doc.dispatchEvent(new Event("visitorConsentCollected")); + resolve(module); await tick(); assert.equal(starts, 0); + allowed = true; doc.dispatchEvent(new Event("visitorConsentCollected")); + await tick(); assert.equal(starts, 1); assert.equal(win.acVisitTrackingDisabled, false); + allowed = false; doc.dispatchEvent(new Event("visitorConsentCollected")); + assert.equal(stops, 1); assert.equal(win.acVisitTrackingDisabled, true); + allowed = true; doc.dispatchEvent(new Event("visitorConsentCollected")); + await tick(); assert.equal(starts, 2); +}); diff --git a/toolchain/analytics/VISITS.md b/toolchain/analytics/VISITS.md index 9444980809..9bfe55e8a0 100644 --- a/toolchain/analytics/VISITS.md +++ b/toolchain/analytics/VISITS.md @@ -79,6 +79,11 @@ cd /opt/ac/system node --env-file=.env ../toolchain/analytics/visits-report.mjs --hours 72 ``` +The default scope is **Studio**. Use `--scope clients` for client properties or +`--scope all` for both. Scope applies to every table, time period and earliest +retained event. Classification is derived from the reviewed canonical domain, +including older records; clients never enter the default studio totals. + Optional `--end 2026-09-26T20:00:00Z` makes a report reproducible. Rows group by property, automation classification and broad surface. Report automation separately; subtract interacted from visits for unknown audience. The sum of @@ -99,8 +104,13 @@ when those domains serve it. Static entry pages cover Whistlegraph, Jas, KidLisp, Prompt, Aesel, Just Another System, Quiltnet and the public AC paper, giving, bills, pop, NFT and language front doors. Oskiewar uses its standalone shell. Sotce uses its function-generated HTML. Client domains on the same DNS -account (false.work, danzballet.studio, regarde.io, drvkforlife.com) are not -included in the studio measurement catalog without explicit client scope. +account (false.work, danzballet.studio, regarde.io, drvkforlife.com) are +collected under **Clients**, following explicit authorization. Public landing +pages only; draft/labs/builds hosts are excluded. Shopify uses +`visit-shopify.mjs` and the existing +[Customer Privacy API](https://shopify.dev/docs/api/customer-privacy) +`analyticsProcessingAllowed()` decision. Denial or revocation stops collection; +regrant begins a fresh visit. Merchant settings and consent are never changed. www aliases roll up to the same property. Reviewed public subdomains are explicit; unlisted hosts are denied. Archived RDP painting pages and other static documents without the script are not automatically covered. diff --git a/toolchain/analytics/visits-coverage.mjs b/toolchain/analytics/visits-coverage.mjs index 93e4d54bb0..7f2570d148 100644 --- a/toolchain/analytics/visits-coverage.mjs +++ b/toolchain/analytics/visits-coverage.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { VISIT_PROPERTIES } from "../../system/public/aesthetic.computer/lib/visit-model.mjs"; +import { VISIT_PROPERTIES, visitGroup } from "../../system/public/aesthetic.computer/lib/visit-model.mjs"; const domains = Object.keys(VISIT_PROPERTIES); const rows = []; for (let i = 0; i < domains.length; i += 4) { @@ -7,9 +7,9 @@ for (let i = 0; i < domains.length; i += 4) { try { const response = await fetch(`https://${property}/`, { signal: AbortSignal.timeout(15000) }); const html = await response.text(); - rows.push({ property, status: response.status, destination: new URL(response.url).hostname, - installed: response.ok && html.includes('/visit-tracker.mjs') }); - } catch { rows.push({ property, installed: false, error: "HTTPS probe failed" }); } + rows.push({ property, group: visitGroup(property), status: response.status, destination: new URL(response.url).hostname, + installed: response.ok && (/\/visit-(?:tracker|shopify)\.mjs/.test(html)) }); + } catch { rows.push({ property, group: visitGroup(property), installed: false, error: "HTTPS probe failed" }); } })); } console.log(JSON.stringify({ checkedAt: new Date(), rows: rows.sort((a, b) => a.property.localeCompare(b.property)) }, null, 2)); diff --git a/toolchain/analytics/visits-report.mjs b/toolchain/analytics/visits-report.mjs index f722ff1042..5f2f20ed50 100644 --- a/toolchain/analytics/visits-report.mjs +++ b/toolchain/analytics/visits-report.mjs @@ -1,26 +1,28 @@ #!/usr/bin/env node // Run on Lith: cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/visits-report.mjs --hours 72 import { connect, closePool } from "../../system/backend/database.mjs"; -import { VISIT_COLLECTION, RETENTION_DAYS, visitReportPipeline } from "../../system/public/aesthetic.computer/lib/visit-model.mjs"; +import { VISIT_COLLECTION, RETENTION_DAYS, visitReportPipeline, visitScopeMatch, visitGroup } from "../../system/public/aesthetic.computer/lib/visit-model.mjs"; const args = process.argv.slice(2); const value = name => args[args.indexOf(name) + 1]; const hours = args.includes("--hours") ? Number(value("--hours")) : 72; const end = args.includes("--end") ? new Date(value("--end")) : new Date(); if (!Number.isFinite(hours) || hours <= 0 || hours > RETENTION_DAYS * 24 || !Number.isFinite(+end)) throw new Error(`Use --hours 1..${RETENTION_DAYS * 24} and optional --end ISO-date`); +const scope = args.includes("--scope") ? value("--scope") : "studio"; +const scopeMatch = visitScopeMatch(scope); const start = new Date(+end - hours * 3600000); const { db } = await connect(); try { const collection = db.collection(VISIT_COLLECTION); - const rows = await collection.aggregate(visitReportPipeline(start, end), { maxTimeMS: 20000 }).toArray(); - const periods = await collection.aggregate(visitReportPipeline(start, end, true), { maxTimeMS: 20000 }).toArray(); - const first = await collection.find({}, { projection: { startedAt: 1 } }).sort({ startedAt: 1 }).limit(1).next(); - console.log(JSON.stringify({ format: "ac.network-visits.v1", start, end, + const rows = await collection.aggregate(visitReportPipeline(start, end, false, scope), { maxTimeMS: 20000 }).toArray(); + const periods = await collection.aggregate(visitReportPipeline(start, end, true, scope), { maxTimeMS: 20000 }).toArray(); + const first = await collection.find(scopeMatch, { projection: { startedAt: 1 } }).sort({ startedAt: 1 }).limit(1).next(); + console.log(JSON.stringify({ format: "ac.network-visits.v1", scope, start, end, earliestRetainedVisit: first?.startedAt || null, retentionDays: RETENTION_DAYS, unit: "page visits, not unique people", - audience: rows.filter(row => !row._id.automated).map(row => ({ ...row, unknownAudience: row.visits - row.interacted })), - automation: rows.filter(row => row._id.automated), - periods: periods.map(row => ({ ...row, + audience: rows.filter(row => !row._id.automated).map(row => ({ ...row, group: visitGroup(row._id.property), unknownAudience: row.visits - row.interacted })), + automation: rows.filter(row => row._id.automated).map(row => ({ ...row, group: visitGroup(row._id.property) })), + periods: periods.map(row => ({ ...row, group: visitGroup(row._id.property), start: new Date(+start + row._id.period * 86400000), end: new Date(Math.min(+end, +start + (row._id.period + 1) * 86400000)) })), }, null, 2));