From 1d2358a7198e2c5927c4a0833962af9746d92beb Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 30 Sep 2026 16:59:53 -0700 Subject: [PATCH 1/3] Record verified account activity and referral sites in Lith analytics --- system/backend/account-activity-handler.mjs | 46 +++++++++ system/backend/account-deletion.mjs | 1 + system/netlify/functions/account-activity.mjs | 5 + system/netlify/functions/sotce-net.mjs | 12 +++ system/public/aesthetic.computer/bios.mjs | 5 + .../lib/account-activity-model.mjs | 28 ++++++ .../lib/account-activity.mjs | 67 +++++++++++++ .../aesthetic.computer/lib/visit-model.mjs | 16 +++- .../aesthetic.computer/lib/visit-tracker.mjs | 4 +- system/public/network-privacy.html | 19 +++- system/tests/account-activity.test.mjs | 94 +++++++++++++++++++ system/tests/account-deletion.test.mjs | 2 + system/tests/journey-report.test.mjs | 76 +++++++++++++++ system/tests/visit-tracking-browser.test.mjs | 3 +- toolchain/analytics/VISITS.md | 44 ++++++++- toolchain/analytics/journey-report.mjs | 74 +++++++++++++++ toolchain/mcp/analytics-mcp.mjs | 26 +++++ 17 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 system/backend/account-activity-handler.mjs create mode 100644 system/netlify/functions/account-activity.mjs create mode 100644 system/public/aesthetic.computer/lib/account-activity-model.mjs create mode 100644 system/public/aesthetic.computer/lib/account-activity.mjs create mode 100644 system/tests/account-activity.test.mjs create mode 100644 system/tests/journey-report.test.mjs create mode 100644 toolchain/analytics/journey-report.mjs diff --git a/system/backend/account-activity-handler.mjs b/system/backend/account-activity-handler.mjs new file mode 100644 index 0000000000..758a5d6ee0 --- /dev/null +++ b/system/backend/account-activity-handler.mjs @@ -0,0 +1,46 @@ +import { validateAccountActivity, ACCOUNT_ACTIVITY_COLLECTION } from "../public/aesthetic.computer/lib/account-activity-model.mjs"; +import { automatedVisit, RETENTION_DAYS } from "../public/aesthetic.computer/lib/visit-model.mjs"; +import { respond } from "./http.mjs"; +import { createHash } from "node:crypto"; + +export function createAccountActivityHandler({ authorize, connect }) { + let indexes; + const rates = new Map(); + return async event => { + if (event.httpMethod === "OPTIONS") return respond(204, ""); + if (event.httpMethod !== "POST") return respond(405, { error: "POST required" }); + if (event.isBase64Encoded || typeof event.body !== "string" || event.body.length > 2048) + return respond(400, { error: "Invalid activity" }); + let body; + try { body = JSON.parse(event.body); } catch { return respond(400, { error: "Invalid JSON" }); } + const activity = validateAccountActivity(body, event.headers?.origin); + if (!activity) return respond(400, { error: "Invalid activity" }); + if (activity.automated || automatedVisit({ userAgent: event.headers?.["user-agent"] })) return respond(204, ""); + try { + const user = await authorize(event.headers || {}, activity.tenant); + if (!user?.sub) return respond(401, { error: "Authentication required" }); + const now = new Date(), owner = `${activity.tenant}:${user.sub}`; + for (const [key, row] of rates) if (row.until <= +now) rates.delete(key); + const rate = rates.get(owner) || { count: 0, until: +now + 60000 }; + if (rates.size >= 10000 && !rates.has(owner)) return respond(429, { error: "Activity rate limit" }); + rates.set(owner, rate); + if (++rate.count > 240) return respond(429, { error: "Activity rate limit" }); + const { db } = await connect(); + const collection = db.collection(ACCOUNT_ACTIVITY_COLLECTION); + indexes ||= Promise.all([ + collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }), + collection.createIndex({ user: 1, at: -1 }), + collection.createIndex({ at: -1 }), + ]).catch(error => { indexes = null; throw error; }); + await indexes; + const { id, automated, ...fields } = activity; + try { + const key = createHash("sha256").update(`${owner}:${id}`).digest("hex"); + await collection.updateOne({ _id: key }, { $setOnInsert: { + ...fields, user: user.sub, at: now, expiresAt: new Date(+now + RETENTION_DAYS * 86400000), + } }, { upsert: true }); + } catch (error) { if (error.code !== 11000) throw error; } + return respond(204, "", { "Cache-Control": "no-store" }); + } catch { return respond(503, { error: "Activity recording unavailable" }); } + }; +} diff --git a/system/backend/account-deletion.mjs b/system/backend/account-deletion.mjs index 09e1286906..5026db51d2 100644 --- a/system/backend/account-deletion.mjs +++ b/system/backend/account-deletion.mjs @@ -308,6 +308,7 @@ const DELETE = [ ["mimechan", (sub) => ({ user: sub })], ["hearts", (sub) => ({ user: sub })], ["piece-user-hits", (sub) => ({ user: sub })], + ["account-activity", (sub) => ({ user: sub, tenant: "aesthetic" })], ["cal-feeds", (sub) => ({ user: sub })], ["calendar", (sub) => ({ user: sub })], ["laklok-themes", (sub) => ({ _id: sub })], diff --git a/system/netlify/functions/account-activity.mjs b/system/netlify/functions/account-activity.mjs new file mode 100644 index 0000000000..e3a690593d --- /dev/null +++ b/system/netlify/functions/account-activity.mjs @@ -0,0 +1,5 @@ +import { authorize } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { createAccountActivityHandler } from "../../backend/account-activity-handler.mjs"; + +export const handler = createAccountActivityHandler({ authorize, connect }); diff --git a/system/netlify/functions/sotce-net.mjs b/system/netlify/functions/sotce-net.mjs index 84620e1b3a..e46342dcbb 100644 --- a/system/netlify/functions/sotce-net.mjs +++ b/system/netlify/functions/sotce-net.mjs @@ -9929,6 +9929,16 @@ export const handler = async (event, context) => { ? window.sotceUSER : await auth0Client.getUser(); + // First-party account activity: verified at the receiving API. + import("https://aesthetic.computer/aesthetic.computer/lib/account-activity.mjs").then(({ startAccountActivity }) => { + const activity = startAccountActivity(window, document, { + getUser: () => user, + getToken: () => window.sotceTOKEN || auth0Client.getTokenSilently(), + }); + activity.load("aesthetic.computer/disks/sotce"); + activity.ready(); + }).catch(() => {}); + // Load the entire history so scrollback reaches the very first page. // (don't set pageNumber, which would limit the server to one page) const subscribeOptions = { loadAll: true }; @@ -10562,6 +10572,7 @@ export const handler = async (event, context) => { } function logout() { + window.acAccountActivity?.stop(); if (isAuthenticated) { console.log("🔐 Logging out...", window.location.href); chat?.system?.server?.send("logout"); // Log out of chat. @@ -11449,6 +11460,7 @@ export const handler = async (event, context) => { // 2. Delete any user data, like posts. const database = await connect(); + await database.db.collection("account-activity").deleteMany({ user: sub, tenant: "sotce" }); // 🗨️ Clear any chat messages owned by the user. // Rewrite the "text" field to be null / empty and rewrite the user field to be empty diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 6494383eb0..08a837017f 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -1,5 +1,6 @@ import { NOPAINT_SESSION_SEED_KEY, noPaintHistoryTarget } from "./lib/nopaint-navigation.mjs"; import { visitMediaAction } from "./lib/visit-model.mjs"; +import { startAccountActivity } from "./lib/account-activity.mjs"; // 💻 BIOS @@ -1052,6 +1053,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { let diskSupervisor; let currentPiece = null; // Gets set to a path after `loaded`. + const accountActivity = startAccountActivity(); let currentPieceHasKeyboard = false; let keyboardMaxChars = 256; // Default; pieces can override via keyboard:set-max-chars @@ -13645,6 +13647,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { } if (type === "logout") { + accountActivity.stop(); if (window.acTOKEN) { if (window.parent) { window.parent.postMessage({ type: "logout" }, "*"); @@ -14497,6 +14500,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { // Initialize some global stuff after the first piece loads. // Unload some already initialized stuff if this wasn't the first load. if (type === "disk-loaded") { + accountActivity.load(content.path); // console.log(`🔍 BIOS: Received disk-loaded for "${content.text}", path="${content.path}"`); // 🐚 Tell a hosting shell (prompt.ac) about every load, including the @@ -19400,6 +19404,7 @@ async function boot(parsed, bpm = 60, resolution, debug) { if (currentPiece !== null) { perf.markBoot("disk-loaded-and-booted"); window.acBOOT_SUCCESS?.(); // Idempotent across later navigations. + accountActivity.ready(); } // Skip preload marker on default init piece, and toggle it if necessary. diff --git a/system/public/aesthetic.computer/lib/account-activity-model.mjs b/system/public/aesthetic.computer/lib/account-activity-model.mjs new file mode 100644 index 0000000000..85ef9e95f2 --- /dev/null +++ b/system/public/aesthetic.computer/lib/account-activity-model.mjs @@ -0,0 +1,28 @@ +import { VISIT_ACTIONS, visitProperty, visitSurface, visitReferrer, visitGroup } from "./visit-model.mjs"; + +export const ACCOUNT_ACTIVITY_COLLECTION = "account-activity"; +export const ACCOUNT_ACTIONS = Object.freeze(["piece_opened", ...VISIT_ACTIONS]); +export const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i; + +export function activityPiece(path) { + const builtIn = /^aesthetic\.computer\/disks\/([a-z0-9-]{1,64})$/i.exec(path || ""); + if (!builtIn) return "published-or-code"; + const piece = builtIn[1].toLowerCase(); + return visitSurface(`/${piece}`) === null ? null : piece; +} + +export function validateAccountActivity(body, origin) { + let url; + try { url = new URL(origin); } catch { return null; } + const property = visitProperty(url.hostname); + if (!property || visitGroup(property) !== "studio" || url.protocol !== "https:" || url.port || + body?.version !== 1 || !UUID.test(body.id || "") || !UUID.test(body.session || "") || + !Number.isSafeInteger(body.sequence) || body.sequence < 1 || body.sequence > 1000000 || + !ACCOUNT_ACTIONS.includes(body.action) || typeof body.piece !== "string" || + !/^[a-z0-9-]{1,64}$/.test(body.piece) || visitSurface(`/${body.piece}`) === null || + typeof body.automated !== "boolean") return null; + return { id: body.id.toLowerCase(), session: body.session.toLowerCase(), sequence: body.sequence, property, + tenant: property === "sotce.net" ? "sotce" : "aesthetic", piece: body.piece, + action: body.action, automated: body.automated, + referrerHost: typeof body.referrerHost === "string" ? visitReferrer(body.referrerHost) : null }; +} diff --git a/system/public/aesthetic.computer/lib/account-activity.mjs b/system/public/aesthetic.computer/lib/account-activity.mjs new file mode 100644 index 0000000000..ccbb677841 --- /dev/null +++ b/system/public/aesthetic.computer/lib/account-activity.mjs @@ -0,0 +1,67 @@ +import { activityPiece } from "./account-activity-model.mjs"; +import { automatedVisit, visitProperty, visitSurface, visitReferrer, VISIT_ACTIONS } from "./visit-model.mjs"; + +// Authenticated activity has its own short-lived session and endpoint. It does +// not add an account field or a join identifier to anonymous network visits. +export function startAccountActivity(win = window, doc = document, { + getUser = () => win.acUSER, + getToken = () => win.acTOKEN || win.auth0Client?.getTokenSilently(), +} = {}) { + if (win.acAccountActivity) return win.acAccountActivity; + let piece = null, ready = false, owner = null, session = null, generation = 0, sequence = 0, stopped = false; + let sent = new Set(), pending = new Set(), retryAt = 0; + const allowed = () => !stopped && win === win.top && doc.visibilityState === "visible" && + !win.acPACK_MODE && !win.acVisitTrackingDisabled && + win.navigator.doNotTrack !== "1" && win.doNotTrack !== "1" && !win.navigator.globalPrivacyControl && + visitProperty(win.location.hostname) && visitSurface(win.location.pathname) !== null && + !(visitProperty(win.location.hostname) === "sotce.net" && /^\/(?:write|ask|respond)(?:\/|$)/.test(win.location.pathname)) && + !automatedVisit(win.navigator, win.location.search, win.acAutomation === true); + function syncOwner() { + const next = getUser()?.sub || null; + if (next !== owner) { + owner = next; session = next ? win.crypto.randomUUID() : null; + sent = new Set(); pending = new Set(); generation++; sequence = 0; retryAt = 0; + } + } + async function record(action) { + syncOwner(); + if (!owner || !piece || !ready || !allowed() || sent.has(action) || pending.has(action) || Date.now() < retryAt) return; + const epoch = generation, subject = owner, activeSession = session, activePiece = piece; + const order = ++sequence; + const activePending = pending, activeSent = sent; + activePending.add(action); + let timeout; + const controller = new AbortController(); + try { + const expired = new Promise((_, reject) => { timeout = win.setTimeout(() => { controller.abort(); reject(new Error("activity timeout")); }, 10000); }); + const token = await Promise.race([Promise.resolve().then(getToken), expired]); + if (!token || epoch !== generation || subject !== getUser()?.sub || !allowed()) return; + const response = await win.fetch("https://aesthetic.computer/api/account-activity", { + method: "POST", credentials: "omit", referrerPolicy: "no-referrer", signal: controller.signal, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ version: 1, id: win.crypto.randomUUID(), session: activeSession, + sequence: order, piece: activePiece, action, automated: false, referrerHost: visitReferrer(doc.referrer) }), + }); + if (response.ok) activeSent.add(action); + else retryAt = Date.now() + 30000; + } catch { retryAt = Date.now() + 30000; } + finally { win.clearTimeout(timeout); activePending.delete(action); } + } + const timer = win.setInterval(() => { + syncOwner(); + if (visitSurface(win.location.pathname) === null) { + session = owner ? win.crypto.randomUUID() : null; + sent.clear(); generation++; + return; + } + void record("piece_opened"); + }, 2000); + const api = { + load(path) { piece = activityPiece(path); ready = false; sent = new Set(); pending = new Set(); generation++; }, + ready() { ready = true; void record("piece_opened"); }, + action(name) { if (VISIT_ACTIONS.includes(name)) void record(name); }, + stop() { stopped = true; generation++; win.clearInterval(timer); if (win.acAccountActivity === api) delete win.acAccountActivity; }, + }; + win.acAccountActivity = api; + return api; +} diff --git a/system/public/aesthetic.computer/lib/visit-model.mjs b/system/public/aesthetic.computer/lib/visit-model.mjs index fb66dd71b5..17e423b6e8 100644 --- a/system/public/aesthetic.computer/lib/visit-model.mjs +++ b/system/public/aesthetic.computer/lib/visit-model.mjs @@ -54,6 +54,19 @@ export const INPUTS = Object.freeze(["pointer", "touch", "keyboard", "scroll", " export const RETENTION_DAYS = 35; export const VISIT_COLLECTION = "network-visits"; +// Referral site only. Paths, credentials, searches and fragments never survive. +export function visitReferrer(value) { + if (!value) return null; + try { + const url = new URL(value.includes("://") ? value : `https://${value}`); + const host = url.hostname.toLowerCase().replace(/^www\./, ""); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || + host.length > 253 || !/^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/.test(host) || + /^[\d.]+$/.test(host) || /\.(?:local|internal|localhost)$/.test(host)) return null; + return host; + } catch { return null; } +} + export function visitGroup(property) { return CLIENT_VISIT_PROPERTIES.includes(property) ? "clients" : "studio"; } @@ -110,6 +123,7 @@ export function validateVisit(body, origin, userAgent = "") { activeSeconds: body.activeSeconds, interacted: body.interacted, automated: body.automated || automatedVisit({ userAgent }), inputs: [...new Set(body.inputs)], actions: [...new Set(body.actions)], + referrerHost: typeof body.referrerHost === "string" ? visitReferrer(body.referrerHost) : null, }; } @@ -119,7 +133,7 @@ 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, group: visitGroup(visit.property), surface: visit.surface, + $setOnInsert: { property: visit.property, group: visitGroup(visit.property), surface: visit.surface, referrerHost: visit.referrerHost, expiresAt: new Date(now.getTime() + RETENTION_DAYS * 86400000) }, $min: { startedAt: now }, $max: { ...max, lastSeenAt: now }, }; diff --git a/system/public/aesthetic.computer/lib/visit-tracker.mjs b/system/public/aesthetic.computer/lib/visit-tracker.mjs index d2507eac45..314a0dba90 100644 --- a/system/public/aesthetic.computer/lib/visit-tracker.mjs +++ b/system/public/aesthetic.computer/lib/visit-tracker.mjs @@ -1,4 +1,4 @@ -import { ACTIVE_BUCKETS, VISIT_ACTIONS, automatedVisit, visitProperty, visitSurface } from "./visit-model.mjs"; +import { ACTIVE_BUCKETS, VISIT_ACTIONS, automatedVisit, visitProperty, visitSurface, visitReferrer } from "./visit-model.mjs"; const ENDPOINT = "https://aesthetic.computer/api/visit-track"; @@ -26,6 +26,7 @@ export function startVisitTracker(win = window, doc = document) { version: 1, id: win.crypto.randomUUID(), surface, automated: automatedVisit(nav, win.location.search, win.acAutomation === true), interacted: false, activeSeconds: 0, inputs: [], actions: [], + referrerHost: visitReferrer(doc.referrer), }; visibleMs = 0; lastSent = ""; lastTick = win.performance.now(); wasVisible = visible(); @@ -82,6 +83,7 @@ export function startVisitTracker(win = window, doc = document) { if (!VISIT_ACTIONS.includes(name) || !visible() || disabled()) return false; syncRoute(); if (!state?.interacted) return false; + win.acAccountActivity?.action(name); if (!state.actions.includes(name)) state.actions.push(name); send(); return true; }; diff --git a/system/public/network-privacy.html b/system/public/network-privacy.html index 2b465a9e5a..9d095a20d6 100644 --- a/system/public/network-privacy.html +++ b/system/public/network-privacy.html @@ -11,7 +11,7 @@ can understand which projects people open, interact with, and use.

A random identifier lasts for one page visit. It is held in memory, with no tracking cookie or persistent browser identifier. We do not connect visits across websites. Each summary contains the property, a broad page category, -visible-time bucket, interaction types and a few action flags such as following +visible-time bucket, referring website hostname when available, interaction types and a few action flags such as following a link, triggering a musical note, accepting a painting edit, starting a recording, saving media or completing a game round. Actions are yes/no flags per visit, not recordings of notes, drawings or clicks. The tracker does not send page text, full @@ -25,6 +25,23 @@ which expires after one minute and is not written into visit records.

frames and private/admin routes are excluded. Browser blocking, offline use and unrecognized automation mean the measurements are estimates of activity, not a count of unique people.

+

Signed-in activity

+

In the Aesthetic Computer runtime and Sotce Net, we also record signed-in +activity against the account verified by our authentication service. This +includes public built-in piece names (other programs use a broad category), +reviewed action milestones, the referring website hostname, server receipt +time, and a temporary session identifier. Sotce records the site category, +not diary page numbers or contents. Repeated actions are flags per piece load, +not a log of every note or stroke.

+

This feed lets our administrators follow account activity and resolve public +handles. It is not joined to anonymous visit identifiers and does not assign +earlier anonymous activity to an account. Records expire after 35 days. The +same privacy controls and private-route exclusions apply. Chat, form contents, +full referring URLs and search parameters are excluded. Account activity is +not proof that a unique human was present.

+

A missing referrer means direct or unavailable: browsers, apps and redirects +can omit it. These records stay in our first-party stack; they are not exported +to PostHog. Existing operational logs and their retention are separate.

App launches

Updated September 28, 2026.

Our native apps (Menu Band, MacPal, Slab, Aesel, Oskiewar, the Aesthetic diff --git a/system/tests/account-activity.test.mjs b/system/tests/account-activity.test.mjs new file mode 100644 index 0000000000..175e93a90d --- /dev/null +++ b/system/tests/account-activity.test.mjs @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { createAccountActivityHandler } from "../backend/account-activity-handler.mjs"; +import { startAccountActivity } from "../public/aesthetic.computer/lib/account-activity.mjs"; +import { visitReferrer } from "../public/aesthetic.computer/lib/visit-model.mjs"; +import { activityPiece, validateAccountActivity } from "../public/aesthetic.computer/lib/account-activity-model.mjs"; + +const snapshot = () => ({ version: 1, id: randomUUID(), session: randomUUID(), sequence: 1, piece: "notepat", action: "note_played", automated: false, referrerHost: "example.org" }); +test("referral reporting keeps only public site names", () => { + assert.equal(visitReferrer("https://www.example.org/private?q=secret#fragment"), "example.org"); + for (const ref of [null,"","file:///secret","https://user:password@example.org/path","http://127.0.0.1/a","http://host.local/a","http://[::1]/a"]) + assert.equal(visitReferrer(ref), null, ref); + assert.equal(activityPiece("aesthetic.computer/disks/notepat"), "notepat"); + assert.equal(activityPiece("aesthetic.computer/disks/chat"), null); + assert.equal(activityPiece("private-inline-source"), "published-or-code"); + assert.equal(validateAccountActivity({ ...snapshot(), piece: "mail" }, "https://aesthetic.computer"), null); + assert.equal(validateAccountActivity(snapshot(), "https://false.work"), null); +}); + +test("account recording requires verified auth and cannot be attributed by client-supplied user or handle", async () => { + const writes = [], indexes = []; + let subject = null, tenant; + const handler = createAccountActivityHandler({ + authorize: async (_headers, name) => { tenant = name; return subject; }, + connect: async () => ({ db: { collection: () => ({ + createIndex: async (keys, options) => indexes.push({ keys, options }), + updateOne: async (...args) => writes.push(args), + }) } }), + }); + const body = { ...snapshot(), user: "forged-user", handle: "forged-handle", referrerHost: "https://example.org/secret?token=private" }; + const event = { httpMethod: "POST", headers: { origin: "https://aesthetic.computer", "user-agent": "Mozilla/5.0" }, body: JSON.stringify(body) }; + assert.equal((await handler(event)).statusCode, 401); + assert.equal(writes.length, 0); + subject = { sub: "verified-subject" }; + assert.equal((await handler(event)).statusCode, 204); + assert.equal(tenant, "aesthetic"); + const row = writes[0][1].$setOnInsert; + assert.equal(row.user, "verified-subject"); + assert.equal(row.referrerHost, "example.org"); + assert.equal(+row.expiresAt - +row.at, 35 * 86400000); + assert.doesNotMatch(JSON.stringify(writes), /forged|secret|token=private/); + await handler(event); + assert.deepEqual(writes[0][0], writes[1][0], "retry ID is stable and insert-only"); + assert.ok(indexes.some(i => i.options?.expireAfterSeconds === 0)); + await handler({ ...event, headers: { ...event.headers, origin: "https://sotce.net" } }); + assert.equal(tenant, "sotce", "tenant derives from reviewed origin"); + const count = writes.length; + await handler({ ...event, body: JSON.stringify({ ...body, automated: true }) }); + assert.equal(writes.length, count, "known automation does not enter authenticated activity"); + assert.equal((await handler({ ...event, httpMethod: "GET" })).statusCode, 405); +}); + +function browserFixture() { + const sent = []; + let tick, user = null, token = () => "token", disabled = false; + const doc = { visibilityState: "visible", referrer: "https://example.org/path?secret=1" }; + const win = { navigator: {}, crypto: { randomUUID }, location: { hostname: "aesthetic.computer", pathname: "/notepat", search: "" }, + setInterval(fn) { tick = fn; return 1; }, clearInterval() {}, setTimeout, clearTimeout, + get acVisitTrackingDisabled() { return disabled; }, + fetch: async (_url, options) => { sent.push(options); return { ok: true }; }, + }; + win.top = win; + const api = startAccountActivity(win, doc, { getUser: () => user, getToken: () => token() }); + return { win, doc, api, sent, tick: () => tick(), user: value => { user = value; }, token: fn => { token = fn; }, disable: () => { disabled = true; } }; +} +const settle = () => new Promise(resolve => setImmediate(resolve)); +test("authenticated client records public piece changes and deduplicates actions without replaying anonymous use", async () => { + const f = browserFixture(); + f.api.load("aesthetic.computer/disks/notepat"); f.api.ready(); + f.api.action("note_played"); await settle(); assert.equal(f.sent.length, 0); + f.user({ sub: "one" }); f.tick(); await settle(); + f.api.action("note_played"); f.api.action("note_played"); await settle(); + assert.deepEqual(f.sent.map(x => JSON.parse(x.body).action), ["piece_opened", "note_played"]); + const first = JSON.parse(f.sent[0].body); + assert.equal(first.referrerHost, "example.org"); + assert.equal(f.sent[0].headers.Authorization, "Bearer token"); + f.api.load("aesthetic.computer/disks/nopaint"); f.api.ready(); await settle(); + assert.equal(JSON.parse(f.sent.at(-1).body).session, first.session); + f.win.location.pathname = "/mail"; f.api.action("painting_saved"); f.tick(); await settle(); + assert.equal(f.sent.length, 3); + f.win.location.pathname = "/notepat"; f.user({ sub: "two" }); f.tick(); await settle(); + assert.notEqual(JSON.parse(f.sent.at(-1).body).session, first.session); + const count = f.sent.length; f.disable(); f.api.action("note_played"); await settle(); assert.equal(f.sent.length, count); + f.api.stop(); +}); +test("logout or opt-out during token retrieval prevents late account attribution", async () => { + for (const change of [f => f.user(null), f => f.disable()]) { + const f = browserFixture(); let release; + f.user({ sub: "one" }); f.token(() => new Promise(resolve => { release = resolve; })); + f.api.load("aesthetic.computer/disks/notepat"); f.api.ready(); await settle(); + change(f); release("token"); await settle(); assert.equal(f.sent.length, 0); f.api.stop(); + } +}); diff --git a/system/tests/account-deletion.test.mjs b/system/tests/account-deletion.test.mjs index 8a22906d99..fd0fe0e7e1 100644 --- a/system/tests/account-deletion.test.mjs +++ b/system/tests/account-deletion.test.mjs @@ -186,6 +186,7 @@ function world() { tapes: [{ user: SUB, code: "t1" }], "chat-system": [{ user: SUB, text: "hello" }, { user: OTHER, text: "hey" }], "chat-clock": [{ user: SUB, text: "tick" }], + "account-activity": [{ user: SUB, tenant: "aesthetic", action: "note_played" }, { user: OTHER, tenant: "aesthetic", action: "piece_opened" }, { user: SUB, tenant: "sotce", action: "piece_opened" }], logs: [{ users: [SUB], text: "hi @me" }, { users: [OTHER], text: "hi @them" }], "device-creds": [{ _id: SUB, claudeToken: "sk-ant-x", githubPat: "ghp_x" }], "news-posts": [{ user: SUB, code: "n1" }], @@ -268,6 +269,7 @@ test("the purge removes the account everywhere and keeps only what it must, with } assert.equal(db.all("paintings").length, 1, "other people's work stays"); assert.equal(db.all("chat-system").length, 1); + assert.deepEqual(db.all("account-activity"), [{ user: OTHER, tenant: "aesthetic", action: "piece_opened" }, { user: SUB, tenant: "sotce", action: "piece_opened" }]); assert.equal(db.all("device-creds").length, 0, "saved device secrets are deleted"); const kidlisp = Object.fromEntries(db.all("kidlisp").map((k) => [k.code, k])); diff --git a/system/tests/journey-report.test.mjs b/system/tests/journey-report.test.mjs new file mode 100644 index 0000000000..d0251161ce --- /dev/null +++ b/system/tests/journey-report.test.mjs @@ -0,0 +1,76 @@ +// node --experimental-vm-modules --test system/tests/journey-report.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { Query, Aggregator } from "mingo"; +import * as model from "../public/aesthetic.computer/lib/visit-model.mjs"; + +async function report(mode, args, data) { + let output, closed = false; + const db = { collection(name) { + const rows = data[name] || []; + return { + aggregate: pipeline => ({ toArray: async () => new Aggregator(pipeline).run(rows) }), + find(query, options = {}) { + let cursor = new Query(query).find(rows, options.projection); + return { sort(value) { cursor = cursor.sort(value); return this; }, + limit(value) { cursor = cursor.limit(value); return this; }, + toArray: async () => cursor.all() }; + }, + }; + } }; + const context = vm.createContext({ URL, URLSearchParams, + process: { argv: ["node", "report", mode, JSON.stringify(args)] }, + console: { log: value => { output = JSON.parse(value); } }, + }); + const module = new vm.SourceTextModule(await readFile(new URL("../../toolchain/analytics/journey-report.mjs", import.meta.url), "utf8"), { context }); + await module.link(specifier => { + const exports = specifier === "node:crypto" ? { createHash } + : specifier.includes("database.mjs") ? { connect: async () => ({ db }), closePool: async () => { closed = true; } } + : model; + return new vm.SyntheticModule(Object.keys(exports), function () { + for (const [key, value] of Object.entries(exports)) this.setExport(key, value); + }, { context }); + }); + await module.evaluate(); + assert.equal(closed, true); + return output; +} +const at = new Date(Date.now() - 1000); +test("private account report counts distinct tenant/accounts, resolves handles and bounds event detail", async () => { + const data = { + "account-activity": [ + { user: "auth0|one", tenant: "aesthetic", property: "aesthetic.computer", at, session: "raw-session", sequence: 1, action: "piece_opened", piece: "notepat" }, + { user: "auth0|one", tenant: "aesthetic", property: "aesthetic.computer", at, session: "raw-session", sequence: 2, action: "note_played", piece: "notepat" }, + { user: "auth0|two", tenant: "aesthetic", property: "nopaint.art", at, session: "another-session", sequence: 1, action: "piece_opened", piece: "nopaint" }, + ], + "@handles": [{ _id: "auth0|one", handle: "painter" }], + }; + const all = await report("accounts", { limit: 2 }, data); + assert.deepEqual(all.totals, { accounts: 2, events: 3 }); + assert.equal(all.truncated, true); + assert.equal(all.events.length, 2); + assert.doesNotMatch(JSON.stringify(all), /auth0\||raw-session|another-session/); + const one = await report("accounts", { handle: "@painter" }, data); + assert.deepEqual(one.totals, { accounts: 1, events: 2 }); + assert.ok(one.events.every(row => row.account === "@painter")); +}); +test("referral report separates old boots, excludes automation and unmeasured visits, and strips URLs", async () => { + const result = await report("referrers", {}, { + "network-visits": [ + { property: "aesthetic.computer", startedAt: at, automated: false, referrerHost: "example.org", interacted: true, engaged: true }, + { property: "aesthetic.computer", startedAt: at, automated: false }, + { property: "aesthetic.computer", startedAt: at, automated: true, referrerHost: "bot.example" }, + ], + boots: [ + { createdAt: at, meta: { host: "aesthetic.computer", path: "/notepat", referrer: "https://example.org/path?secret=1", user: { sub: "private" } } }, + { createdAt: at, meta: { host: "aesthetic.computer", path: "/mail", referrer: "https://private.example/" } }, + { createdAt: at, meta: { host: "aesthetic.computer", path: "/", userAgent: "SomeBot" } }, + ], + }); + assert.deepEqual(result.visits, [{ property: "aesthetic.computer", referrerHost: "example.org", visits: 1, interacted: 1, engaged: 1 }]); + assert.deepEqual(result.legacyBoots, [{ property: "aesthetic.computer", referrerHost: "example.org", boots: 1 }]); + assert.doesNotMatch(JSON.stringify(result), /secret|private|bot\.example/); +}); diff --git a/system/tests/visit-tracking-browser.test.mjs b/system/tests/visit-tracking-browser.test.mjs index 3f610afffc..debc8611f1 100644 --- a/system/tests/visit-tracking-browser.test.mjs +++ b/system/tests/visit-tracking-browser.test.mjs @@ -22,11 +22,12 @@ test("browser funnel, automation, privacy opt-out, private SPA routes and duplic }); const page = await context.newPage(); await page.clock.install(); - await page.goto("https://nopaint.art/"); + await page.goto("https://nopaint.art/", { referer: "https://example.org/private?secret=1" }); await page.waitForFunction(() => !!window.acVisits); await page.waitForTimeout(50); assert.equal(received.length, 1); assert.equal(received[0].automated, true); + assert.equal(received[0].referrerHost, "example.org"); await page.evaluate(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }))); await page.waitForTimeout(50); assert.equal(received.length, 1, "synthetic input is ignored"); diff --git a/toolchain/analytics/VISITS.md b/toolchain/analytics/VISITS.md index 47acea9820..7bd95fa968 100644 --- a/toolchain/analytics/VISITS.md +++ b/toolchain/analytics/VISITS.md @@ -80,13 +80,18 @@ An origin header and self-reported events are not cryptographic proof of human activity. The collector rejects unreviewed values and oversized bodies and uses a bounded, in-memory rate guard (240 requests/minute/source). -No stored IP, user agent, account/handle, referrer, URL, query string, page text, +No stored IP, user agent, account/handle, full URL, query string, page text, form value, key or pointer coordinate. The transient rate-limit digest is process-salted, expires after one minute and never leaves memory. Requests omit credentials and referrers. No tracking cookies or browser storage are used. DNT, GPC, `window.acVisitTrackingDisabled = true`, private routes and embedded frames suppress collection. The disclosure is `/network-privacy.html`. +New visit records include `referrerHost`: the browser-reported referring +hostname, stripped of credentials, path, query and fragment. Local hosts and +IP literals are excluded. Null means direct or unavailable, not necessarily +direct traffic. Older visit records without this field are unmeasured. + Render/test harnesses should set `window.acAutomation = true` before loading the module, or append `?ac-automation=1`. Existing `social-preview`, `offline-render` and `jev-vs-jev` parameters also mark automation. Headless @@ -151,6 +156,43 @@ The tool reads existing data; it adds no browser identifiers or new retention. ## Coverage +### Account activity and referrers + +`POST /api/account-activity` verifies a bearer token through the existing +authorization service. Identity is taken only from the verified account; +submitted user/handle fields are ignored. AC shell piece loads and reviewed +actions use a separate in-memory session and client sequence. Built-in public +piece names are retained; published/inline programs use `published-or-code`. +Sotce uses its own authentication tenant and the broad `sotce` category, without +diary page IDs or contents. Embedded shells and private routes are excluded. +Only signed-in activity after installation is available. Login does not replay +anonymous actions, and there is no join to anonymous visit IDs. + +`account-activity` stores server receipt time, verified account subject, tenant, +property, session, sequence, piece, action and referral hostname. Actions dedupe +per piece load; receipt order can differ from client sequence. The endpoint has +no public read route, bounded requests and a per-account rate limit. Rows expire +after 35 days; each site's account deletion removes its tenant's rows. Separate +Sotce identities remain separate accounts. Existing Silo operational firehose +history has its own retention. These records are not sent to PostHog. + +The private analytics MCP exposes: + +- `account_activity({hours:24, handle:"@handle"})`: verified account events, + public handles where available, otherwise an account alias, and temporary + session aliases. Counts are accounts, not unique people. Omit `handle` for + all recorded accounts. Results are bounded and report truncation. +- `network_referrers({hours:24})`: referral hosts grouped by property, with + visits, interacted visits and engaged visits. Existing AC boot logs supply + a separate historical referral table, stripped to hostnames. Do not add boot + counts to visit counts; they measure different things. Neither table proves + human identity or a complete marketing attribution chain. + +Both default to studio scope and accept `limit` (up to 500). No campaign tags +are collected. Missing data before deployment cannot be reconstructed. + +### Website installation + The shared AC shell covers AC, notepat.com, nopaint.art, laklok.com and mime.ac when those domains serve it. Static entry pages cover Whistlegraph, Jas, KidLisp, Prompt, Aesel, Just Another System, Quiltnet and the public AC paper, diff --git a/toolchain/analytics/journey-report.mjs b/toolchain/analytics/journey-report.mjs new file mode 100644 index 0000000000..d14263b064 --- /dev/null +++ b/toolchain/analytics/journey-report.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Private CLI transport: SSH/MCP only, no public account-activity read endpoint. +import { createHash } from "node:crypto"; +import { connect, closePool } from "../../system/backend/database.mjs"; +import { visitScopeMatch, visitReferrer, visitProperty, visitSurface, automatedVisit } from "../../system/public/aesthetic.computer/lib/visit-model.mjs"; + +const mode = process.argv[2]; +const { hours = 24, limit = 100, handle, scope = "studio" } = JSON.parse(process.argv[3] || "{}"); +if (!["accounts", "referrers"].includes(mode) || !Number.isFinite(hours) || hours <= 0 || hours > 840 || + !Number.isInteger(limit) || limit < 1 || limit > 500 || + (handle !== undefined && !/^@?[a-z0-9_-]{1,64}$/i.test(handle))) throw new Error("Invalid report options"); +const scoped = visitScopeMatch(scope), end = new Date(), start = new Date(+end - hours * 3600000); +const { db } = await connect(); +try { + if (mode === "accounts") { + const query = { at: { $gte: start, $lt: end }, ...scoped }; + if (handle) { + const handles = await db.collection("@handles").find({ handle: handle.replace(/^@/, "") }, { projection: { _id: 1 } }).limit(10).toArray(); + query.$or = handles.map(row => String(row._id).startsWith("sotce-") + ? { tenant: "sotce", user: String(row._id).slice(6) } + : { tenant: "aesthetic", user: String(row._id) }); + if (!query.$or.length) query.$or = [{ user: { $in: [] } }]; + } + const collection = db.collection("account-activity"); + const [totals = { accounts: 0, events: 0 }] = await collection.aggregate([ + { $match: query }, + { $group: { _id: { tenant: "$tenant", user: "$user" }, events: { $sum: 1 } } }, + { $group: { _id: null, accounts: { $sum: 1 }, events: { $sum: "$events" } } }, + { $project: { _id: 0, accounts: 1, events: 1 } }, + ], { maxTimeMS: 10000 }).toArray(); + const rows = await collection.find(query, { + projection: { _id: 0, tenant: 1, user: 1, session: 1, sequence: 1, at: 1, property: 1, piece: 1, action: 1, referrerHost: 1 }, maxTimeMS: 10000, + }).sort({ at: -1, _id: -1 }).limit(limit + 1).toArray(); + const key = row => row.tenant === "sotce" ? `sotce-${row.user}` : row.user; + const names = new Map((await db.collection("@handles").find({ _id: { $in: [...new Set(rows.map(key))] } }, { projection: { handle: 1 } }).toArray()).map(row => [String(row._id), row.handle])); + const short = value => createHash("sha256").update(value).digest("hex").slice(0, 12); + console.log(JSON.stringify({ start, end, scope, totals, truncated: rows.length > limit, + identity: "server-verified account; authenticated activity is not proof of a human", + note: "Only activity recorded after deployment is available. Session aliases group one runtime login, not separate anonymous visits. Referrers are browser-reported sites; null means direct or unavailable.", + events: rows.slice(0, limit).reverse().map(row => ({ at: row.at, + account: names.get(key(row)) ? `@${names.get(key(row))}` : `account-${short(key(row))}`, + tenant: row.tenant, session: short(`${key(row)}:${row.session}`), sequence: row.sequence, property: row.property, + piece: row.piece, action: row.action, referrerHost: row.referrerHost, + })), + })); + } else { + const visits = await db.collection("network-visits").aggregate([ + { $match: { ...scoped, automated: false, startedAt: { $gte: start, $lt: end }, referrerHost: { $exists: true } } }, + { $group: { _id: { property: "$property", referrerHost: "$referrerHost" }, visits: { $sum: 1 }, + interacted: { $sum: { $cond: ["$interacted", 1, 0] } }, engaged: { $sum: { $cond: ["$engaged", 1, 0] } } } }, + { $sort: { visits: -1 } }, { $limit: limit + 1 }, + ], { maxTimeMS: 10000 }).toArray(); + // Existing boot logs provide historical referral context for the AC shell. + // Do not add these totals to visits: the instruments measure different things. + const boots = await db.collection("boots").find({ createdAt: { $gte: start, $lt: end } }, { + projection: { _id: 0, "meta.host": 1, "meta.path": 1, "meta.referrer": 1, "meta.userAgent": 1, "meta.embedded": 1, "meta.packMode": 1, "meta.localDev": 1 }, maxTimeMS: 10000, + }).sort({ createdAt: -1 }).limit(10001).toArray(); + const groups = new Map(); + for (const { meta = {} } of boots.slice(0, 10000)) { + const property = visitProperty(meta.host); + if (!scoped.property.$in.includes(property) || visitSurface(meta.path) === null || meta.embedded || meta.packMode || meta.localDev || automatedVisit({ userAgent: meta.userAgent })) continue; + const referrerHost = visitReferrer(meta.referrer), key = JSON.stringify([property, referrerHost]); + const row = groups.get(key) || { property, referrerHost, boots: 0 }; + row.boots++; groups.set(key, row); + } + console.log(JSON.stringify({ start, end, scope, + note: "Referrer sites only. Null means direct or unavailable, not proof of direct traffic. Browser privacy, apps and redirects can omit referrers. Visit and legacy boot totals must not be added together. No account or visitor identity is inferred from a referrer.", + visits: visits.slice(0, limit).map(row => ({ ...row._id, visits: row.visits, interacted: row.interacted, engaged: row.engaged })), + visitsTruncated: visits.length > limit, + legacyBoots: [...groups.values()].sort((a, b) => b.boots - a.boots).slice(0, limit), + legacyBootsTruncated: boots.length > 10000 || groups.size > limit, + })); + } +} finally { await closePool(); } diff --git a/toolchain/mcp/analytics-mcp.mjs b/toolchain/mcp/analytics-mcp.mjs index e14b208de6..076ebcf6a7 100644 --- a/toolchain/mcp/analytics-mcp.mjs +++ b/toolchain/mcp/analytics-mcp.mjs @@ -49,6 +49,17 @@ async function humanFishery(args = {}) { return JSON.parse(stdout.slice(stdout.indexOf("{"))); } +async function journeyReport(mode, args = {}) { + const { hours = 24, limit = 100, scope = "studio", handle } = args; + if (!Number.isFinite(hours) || hours <= 0 || hours > 840 || !Number.isInteger(limit) || limit < 1 || limit > 500 || + !["studio", "clients", "all"].includes(scope) || (handle !== undefined && !/^@?[a-z0-9_-]{1,64}$/i.test(handle))) throw new Error("Invalid report options"); + const quoted = "'" + JSON.stringify({ hours, limit, scope, handle }).replaceAll("'", "'\\''") + "'"; + const remote = `cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/journey-report.mjs ${mode} ${quoted}`; + const { stdout } = await pexec("ssh", ["-i", SSH_KEY, "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", LITH, remote], + { timeout: 30000, maxBuffer: 2 * 1024 * 1024 }); + return JSON.parse(stdout.slice(stdout.indexOf("{"))); +} + // 🌐 Visits async function visitsReport({ hours = 48, scope = "all", end } = {}) { @@ -244,6 +255,19 @@ async function appDownloads({ days = 7, apps = Object.keys(APPS) } = {}) { // 🔌 MCP const TOOLS = [ + ...["account_activity", "network_referrers"].map(name => ({ + name, + description: name === "account_activity" + ? "Follow server-verified authenticated accounts through public AC/Sotce activity: public handle, runtime session alias, piece opens and action milestones. Optional handle filter. Only records from the account-activity rollout onward; no inferred identities or retroactive joins to anonymous fish. Login does not prove human activity. Private SSH-backed read." + : "Referral sites across the Aesthetic network: first-party visits plus separately labeled historical AC boot referrals. Domain only, no full referrer URLs. Null is direct-or-unavailable. Excludes known automation; visits and boots are separate instruments and must not be summed.", + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { type: "object", additionalProperties: false, properties: { + hours: { type: "number", exclusiveMinimum: 0, maximum: 840, description: "Lookback hours; default 24" }, + limit: { type: "integer", minimum: 1, maximum: 500, description: "Maximum rows; default 100" }, + scope: { type: "string", enum: ["studio", "clients", "all"], description: "Default studio" }, + ...(name === "account_activity" ? { handle: { type: "string", description: "Optional public handle, with or without @" } } : {}), + } }, + })), { name: "human_fishery", description: "AC Human Fishery: watch recent likely-human activity through Silo's existing MongoDB firehose on Lith. Returns temporary fish names for non-automated visits with interaction, public property, broad surface, visible-time depth and action flags. Read-only snapshots; repeat after at least 15 seconds for changes. Does not identify people, link separate visits or infer cross-site journeys. lastReportedAt is the last changed snapshot, not proof someone is still online.", @@ -313,6 +337,8 @@ const TOOLS = [ async function callTool(name, args = {}) { const result = name === "visits_report" ? await visitsReport(args) + : name === "account_activity" ? await journeyReport("accounts", args) + : name === "network_referrers" ? await journeyReport("referrers", args) : name === "human_fishery" ? await humanFishery(args) : name === "direct_downloads" ? await directDownloads(args) : name === "daily_metrics" ? await dailyMetrics(args) -- 2.51.2 From 92ad83249bb09213538e9519aec0fba91725cfc4 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 30 Sep 2026 17:18:01 -0700 Subject: [PATCH 2/3] Measure Sotce reading and saved-action milestones in account activity --- system/netlify/functions/sotce-net.mjs | 44 +++++++++++++++++-- .../lib/account-activity-model.mjs | 12 ++++- .../lib/account-activity.mjs | 15 ++++--- .../aesthetic.computer/lib/sotce-activity.mjs | 34 ++++++++++++++ system/public/network-privacy.html | 10 +++-- system/tests/account-activity.test.mjs | 26 ++++++++++- system/tests/journey-report.test.mjs | 4 ++ system/tests/sotce-activity.test.mjs | 44 +++++++++++++++++++ system/tests/sotce-route-fallback.test.mjs | 15 ++++++- toolchain/analytics/VISITS.md | 20 +++++++++ toolchain/analytics/journey-report.mjs | 7 ++- toolchain/mcp/analytics-mcp.mjs | 8 ++-- 12 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 system/public/aesthetic.computer/lib/sotce-activity.mjs create mode 100644 system/tests/sotce-activity.test.mjs diff --git a/system/netlify/functions/sotce-net.mjs b/system/netlify/functions/sotce-net.mjs index e46342dcbb..2587b6a66b 100644 --- a/system/netlify/functions/sotce-net.mjs +++ b/system/netlify/functions/sotce-net.mjs @@ -8596,6 +8596,14 @@ export const handler = async (event, context) => { g.goToPage = goToPage; g.totalPages = totalPages; g.getCurrentPage = () => currentPageIndex; + window.acSotceVisiblePage = () => { + const rect = canvas.getBoundingClientRect(); + const item = pageCache.get(displayedPageIndex); + if (!running || !canvas.isConnected || !item || showingBack || isFlipping || + transitionDirection !== 0 || dragDelta !== 0 || isWheelScrolling || + rect.width <= 0 || rect.height <= 0 || rect.bottom <= 0 || rect.top >= innerHeight) return null; + return (item.type === "question" ? "question:" : "page:") + displayedPageIndex; + }; // Cleanup const observer = new MutationObserver(() => { @@ -9124,6 +9132,17 @@ export const handler = async (event, context) => { } // Initial render - show 3 pages around current + window.acSotceVisiblePage = () => { + if (!binding.isConnected) return null; + const bounds = binding.getBoundingClientRect(); + if (bounds.height <= 0 || bounds.width <= 0 || bounds.bottom <= 0 || bounds.top >= innerHeight) return null; + const center = (Math.max(0, bounds.top) + Math.min(innerHeight, bounds.bottom)) / 2; + for (const [index, page] of renderedPages) { + const rect = page.getBoundingClientRect(); + if (page.dataset.loaded === "true" && !page.classList.contains("reverse") && rect.top <= center && rect.bottom >= center) return "page:" + index; + } + return null; + }; console.log("📖 Virtualized scroll view: starting at page", currentPageIndex, "of", totalPages); await updateVisiblePages(currentPageIndex, true); // skipScroll=true, we'll do it manually @@ -9930,13 +9949,17 @@ export const handler = async (event, context) => { : await auth0Client.getUser(); // First-party account activity: verified at the receiving API. - import("https://aesthetic.computer/aesthetic.computer/lib/account-activity.mjs").then(({ startAccountActivity }) => { + Promise.all([ + import("https://aesthetic.computer/aesthetic.computer/lib/account-activity.mjs"), + import("https://aesthetic.computer/aesthetic.computer/lib/sotce-activity.mjs"), + ]).then(([{ startAccountActivity }, { startSotceActivity }]) => { const activity = startAccountActivity(window, document, { getUser: () => user, getToken: () => window.sotceTOKEN || auth0Client.getTokenSilently(), }); activity.load("aesthetic.computer/disks/sotce"); activity.ready(); + startSotceActivity(window, document); }).catch(() => {}); // Load the entire history so scrollback reaches the very first page. @@ -10572,6 +10595,7 @@ export const handler = async (event, context) => { } function logout() { + window.acSotceActivity?.stop(); window.acAccountActivity?.stop(); if (isAuthenticated) { console.log("🔐 Logging out...", window.location.href); @@ -10690,8 +10714,10 @@ export const handler = async (event, context) => { } else { const clonedResponse = response.clone(); try { + const result = await clonedResponse.json(); + window.acSotceActivity?.response(method, endpoint, response.status, result); return { - ...(await clonedResponse.json()), + ...result, status: response.status, }; } catch (error) { @@ -11390,6 +11416,7 @@ export const handler = async (event, context) => { if (page) { const touches = database.db.collection("sotce-touches"); + let touchCreated = false; // Try to touch the page. if (page.user !== user.sub) { @@ -11403,6 +11430,7 @@ export const handler = async (event, context) => { page: id, // Page ID from the request body when: new Date(), // Current date and time }); + touchCreated = true; } catch (error) { if (error.code === 11000) { // Duplicate key error, meaning the user has already touched this page @@ -11432,7 +11460,7 @@ export const handler = async (event, context) => { } await database.disconnect(); - return respond(200, { touches: handles }); + return respond(200, { touches: handles, touchCreated }); } else { await database.disconnect(); return respond(404, { message: "No page found to touch." }); @@ -11562,7 +11590,7 @@ export const handler = async (event, context) => { await database.disconnect(); shell.log("❓ Question submitted:", insertion.insertedId); - return respond(200, { _id: insertion.insertedId }); + return respond(200, { _id: insertion.insertedId, success: true }); } else if (path === "/asks" && method === "get") { // ❓ Get user's own questions const user = await authorize(event.headers, "sotce"); @@ -11857,6 +11885,14 @@ export const handler = async (event, context) => {

We do not sell your data.

+

+ Our first-party analytics record signed-in page viewing milestones, + newly saved touches, successful question submissions, and referring + website names. They do not include diary or question text or page + identifiers. Records expire after 35 days; Do Not Track and Global + Privacy Control disable collection. + Measurement details. +

Delete your account from the settings page. Write to mail@sotce.net with questions.

diff --git a/system/public/aesthetic.computer/lib/account-activity-model.mjs b/system/public/aesthetic.computer/lib/account-activity-model.mjs index 85ef9e95f2..f266a60c03 100644 --- a/system/public/aesthetic.computer/lib/account-activity-model.mjs +++ b/system/public/aesthetic.computer/lib/account-activity-model.mjs @@ -1,7 +1,16 @@ import { VISIT_ACTIONS, visitProperty, visitSurface, visitReferrer, visitGroup } from "./visit-model.mjs"; export const ACCOUNT_ACTIVITY_COLLECTION = "account-activity"; -export const ACCOUNT_ACTIONS = Object.freeze(["piece_opened", ...VISIT_ACTIONS]); +export const SOTCE_ACTIONS = Object.freeze(["sotce_page_viewed", "sotce_page_visible_30s", "sotce_page_touched", "sotce_question_submitted"]); +export const ACCOUNT_ACTIONS = Object.freeze(["piece_opened", ...VISIT_ACTIONS, ...SOTCE_ACTIONS]); + +export function accountActivityRoute(host, path, action) { + if (visitSurface(path) === null) return false; + if (visitProperty(host) !== "sotce.net") return true; + // A saved-question milestone is the sole event allowed in the ask editor. + if (path === "/ask" && action === "sotce_question_submitted") return true; + return !/^\/(?:write|ask|respond|comment)(?:\/|$)/.test(path); +} export const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i; export function activityPiece(path) { @@ -21,6 +30,7 @@ export function validateAccountActivity(body, origin) { !ACCOUNT_ACTIONS.includes(body.action) || typeof body.piece !== "string" || !/^[a-z0-9-]{1,64}$/.test(body.piece) || visitSurface(`/${body.piece}`) === null || typeof body.automated !== "boolean") return null; + if (SOTCE_ACTIONS.includes(body.action) && (property !== "sotce.net" || body.piece !== "sotce")) return null; return { id: body.id.toLowerCase(), session: body.session.toLowerCase(), sequence: body.sequence, property, tenant: property === "sotce.net" ? "sotce" : "aesthetic", piece: body.piece, action: body.action, automated: body.automated, diff --git a/system/public/aesthetic.computer/lib/account-activity.mjs b/system/public/aesthetic.computer/lib/account-activity.mjs index ccbb677841..4d89fb9d67 100644 --- a/system/public/aesthetic.computer/lib/account-activity.mjs +++ b/system/public/aesthetic.computer/lib/account-activity.mjs @@ -1,4 +1,4 @@ -import { activityPiece } from "./account-activity-model.mjs"; +import { activityPiece, accountActivityRoute, SOTCE_ACTIONS } from "./account-activity-model.mjs"; import { automatedVisit, visitProperty, visitSurface, visitReferrer, VISIT_ACTIONS } from "./visit-model.mjs"; // Authenticated activity has its own short-lived session and endpoint. It does @@ -10,11 +10,10 @@ export function startAccountActivity(win = window, doc = document, { if (win.acAccountActivity) return win.acAccountActivity; let piece = null, ready = false, owner = null, session = null, generation = 0, sequence = 0, stopped = false; let sent = new Set(), pending = new Set(), retryAt = 0; - const allowed = () => !stopped && win === win.top && doc.visibilityState === "visible" && + const allowed = action => !stopped && win === win.top && doc.visibilityState === "visible" && !win.acPACK_MODE && !win.acVisitTrackingDisabled && win.navigator.doNotTrack !== "1" && win.doNotTrack !== "1" && !win.navigator.globalPrivacyControl && - visitProperty(win.location.hostname) && visitSurface(win.location.pathname) !== null && - !(visitProperty(win.location.hostname) === "sotce.net" && /^\/(?:write|ask|respond)(?:\/|$)/.test(win.location.pathname)) && + visitProperty(win.location.hostname) && accountActivityRoute(win.location.hostname, win.location.pathname, action) && !automatedVisit(win.navigator, win.location.search, win.acAutomation === true); function syncOwner() { const next = getUser()?.sub || null; @@ -25,7 +24,9 @@ export function startAccountActivity(win = window, doc = document, { } async function record(action) { syncOwner(); - if (!owner || !piece || !ready || !allowed() || sent.has(action) || pending.has(action) || Date.now() < retryAt) return; + const repeated = SOTCE_ACTIONS.includes(action); + if (repeated && (visitProperty(win.location.hostname) !== "sotce.net" || piece !== "sotce")) return; + if (!owner || !piece || !ready || !allowed(action) || (!repeated && sent.has(action)) || pending.has(action) || Date.now() < retryAt) return; const epoch = generation, subject = owner, activeSession = session, activePiece = piece; const order = ++sequence; const activePending = pending, activeSent = sent; @@ -35,7 +36,7 @@ export function startAccountActivity(win = window, doc = document, { try { const expired = new Promise((_, reject) => { timeout = win.setTimeout(() => { controller.abort(); reject(new Error("activity timeout")); }, 10000); }); const token = await Promise.race([Promise.resolve().then(getToken), expired]); - if (!token || epoch !== generation || subject !== getUser()?.sub || !allowed()) return; + if (!token || epoch !== generation || subject !== getUser()?.sub || !allowed(action)) return; const response = await win.fetch("https://aesthetic.computer/api/account-activity", { method: "POST", credentials: "omit", referrerPolicy: "no-referrer", signal: controller.signal, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, @@ -59,7 +60,7 @@ export function startAccountActivity(win = window, doc = document, { const api = { load(path) { piece = activityPiece(path); ready = false; sent = new Set(); pending = new Set(); generation++; }, ready() { ready = true; void record("piece_opened"); }, - action(name) { if (VISIT_ACTIONS.includes(name)) void record(name); }, + action(name) { if (VISIT_ACTIONS.includes(name) || SOTCE_ACTIONS.includes(name)) void record(name); }, stop() { stopped = true; generation++; win.clearInterval(timer); if (win.acAccountActivity === api) delete win.acAccountActivity; }, }; win.acAccountActivity = api; diff --git a/system/public/aesthetic.computer/lib/sotce-activity.mjs b/system/public/aesthetic.computer/lib/sotce-activity.mjs new file mode 100644 index 0000000000..9748a3bd4e --- /dev/null +++ b/system/public/aesthetic.computer/lib/sotce-activity.mjs @@ -0,0 +1,34 @@ +// Page keys stay in memory. Only reviewed action names reach account telemetry. +export function sotceResponseAction(method, endpoint, status, result) { + if (method.toUpperCase() !== "POST" || status !== 200) return null; + if (endpoint === "/sotce-net/touch-a-page" && result?.touchCreated === true) return "sotce_page_touched"; + if (endpoint === "/sotce-net/ask" && result?.success === true) return "sotce_question_submitted"; + return null; +} + +export function startSotceActivity(win = window, doc = document) { + if (win.acSotceActivity) return win.acSotceActivity; + let key = null, elapsed = 0, viewed = false, read = false, last = win.performance.now(), wasVisible = false; + const timer = win.setInterval(() => { + const now = win.performance.now(), delta = Math.min(1000, Math.max(0, now - last)); + last = now; + const visible = doc.visibilityState === "visible" && !doc.body.classList.contains("pages-hidden") && + !doc.documentElement.classList.contains("editing"); + const page = visible ? win.acSotceVisiblePage?.() : null; + if (!page) { wasVisible = false; return; } + if (page !== key) { key = page; elapsed = 0; viewed = false; read = false; wasVisible = false; } + if (wasVisible) elapsed += delta; + wasVisible = true; + if (!viewed && elapsed >= 2000) { viewed = true; win.acAccountActivity?.action("sotce_page_viewed"); } + if (!read && elapsed >= 30000) { read = true; win.acAccountActivity?.action("sotce_page_visible_30s"); } + }, 1000); + const api = { + response(method, endpoint, status, result) { + const action = sotceResponseAction(method, endpoint, status, result); + if (action) win.acAccountActivity?.action(action); + }, + stop() { win.clearInterval(timer); if (win.acSotceActivity === api) delete win.acSotceActivity; }, + }; + win.acSotceActivity = api; + return api; +} diff --git a/system/public/network-privacy.html b/system/public/network-privacy.html index 9d095a20d6..4940478e94 100644 --- a/system/public/network-privacy.html +++ b/system/public/network-privacy.html @@ -31,12 +31,16 @@ activity against the account verified by our authentication service. This includes public built-in piece names (other programs use a broad category), reviewed action milestones, the referring website hostname, server receipt time, and a temporary session identifier. Sotce records the site category, -not diary page numbers or contents. Repeated actions are flags per piece load, -not a log of every note or stroke.

+not diary page numbers or contents. AC actions are flags per piece load, +not a log of every note or stroke. Sotce also records each displayed-page +milestone after two visible seconds, thirty seconds of foreground page display, +newly saved touches and successful question submissions. These are activity +signals, not proof that someone read a page.

This feed lets our administrators follow account activity and resolve public handles. It is not joined to anonymous visit identifiers and does not assign earlier anonymous activity to an account. Records expire after 35 days. The -same privacy controls and private-route exclusions apply. Chat, form contents, +same privacy controls and private-route exclusions apply, except for the +successful-submission milestone in Sotce's question form. Chat, form contents, full referring URLs and search parameters are excluded. Account activity is not proof that a unique human was present.

A missing referrer means direct or unavailable: browsers, apps and redirects diff --git a/system/tests/account-activity.test.mjs b/system/tests/account-activity.test.mjs index 175e93a90d..682cfd7357 100644 --- a/system/tests/account-activity.test.mjs +++ b/system/tests/account-activity.test.mjs @@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto"; import { createAccountActivityHandler } from "../backend/account-activity-handler.mjs"; import { startAccountActivity } from "../public/aesthetic.computer/lib/account-activity.mjs"; import { visitReferrer } from "../public/aesthetic.computer/lib/visit-model.mjs"; -import { activityPiece, validateAccountActivity } from "../public/aesthetic.computer/lib/account-activity-model.mjs"; +import { activityPiece, validateAccountActivity, SOTCE_ACTIONS } from "../public/aesthetic.computer/lib/account-activity-model.mjs"; const snapshot = () => ({ version: 1, id: randomUUID(), session: randomUUID(), sequence: 1, piece: "notepat", action: "note_played", automated: false, referrerHost: "example.org" }); test("referral reporting keeps only public site names", () => { @@ -92,3 +92,27 @@ test("logout or opt-out during token retrieval prevents late account attribution change(f); release("token"); await settle(); assert.equal(f.sent.length, 0); f.api.stop(); } }); + +test("Sotce action sequences stay in their tenant and private editors permit only the submitted-question milestone", async () => { + for (const action of SOTCE_ACTIONS) { + const body = { ...snapshot(), piece: "sotce", action }; + assert.equal(validateAccountActivity(body, "https://aesthetic.computer"), null); + assert.equal(validateAccountActivity(body, "https://sotce.net").tenant, "sotce"); + } + const f = browserFixture(); + f.win.location.hostname = "sotce.net"; f.win.location.pathname = "/1"; + f.user({ sub: "sotce-user" }); f.api.load("aesthetic.computer/disks/sotce"); f.api.ready(); await settle(); + f.api.action("sotce_page_viewed"); await settle(); + f.api.action("sotce_page_viewed"); await settle(); + assert.equal(f.sent.filter(x => JSON.parse(x.body).action === "sotce_page_viewed").length, 2); + f.win.location.pathname = "/ask"; + const count = f.sent.length; + f.tick(); f.api.action("canvas_interacted"); f.api.action("sotce_page_viewed"); await settle(); + assert.equal(f.sent.length, count); + f.api.action("sotce_question_submitted"); await settle(); assert.equal(f.sent.length, count + 1); + f.win.location.pathname = "/comment"; f.tick(); f.api.action("sotce_page_touched"); await settle(); + assert.equal(f.sent.length, count + 1); + f.win.location.pathname = "/"; f.disable(); f.api.action("sotce_page_viewed"); await settle(); + assert.equal(f.sent.length, count + 1); + f.api.stop(); +}); diff --git a/system/tests/journey-report.test.mjs b/system/tests/journey-report.test.mjs index d0251161ce..b667aabccc 100644 --- a/system/tests/journey-report.test.mjs +++ b/system/tests/journey-report.test.mjs @@ -56,6 +56,10 @@ test("private account report counts distinct tenant/accounts, resolves handles a const one = await report("accounts", { handle: "@painter" }, data); assert.deepEqual(one.totals, { accounts: 1, events: 2 }); assert.ok(one.events.every(row => row.account === "@painter")); + const site = await report("accounts", { property: "nopaint.art" }, data); + assert.deepEqual(site.totals, { accounts: 1, events: 1 }); + assert.ok(site.events.every(row => row.property === "nopaint.art")); + await assert.rejects(report("accounts", { property: "false.work" }, data), /outside the selected scope/); }); test("referral report separates old boots, excludes automation and unmeasured visits, and strips URLs", async () => { const result = await report("referrers", {}, { diff --git a/system/tests/sotce-activity.test.mjs b/system/tests/sotce-activity.test.mjs new file mode 100644 index 0000000000..cf028574c1 --- /dev/null +++ b/system/tests/sotce-activity.test.mjs @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { startSotceActivity, sotceResponseAction } from "../public/aesthetic.computer/lib/sotce-activity.mjs"; + +test("Sotce milestones require successful saved operations, not a click, duplicate touch or error", () => { + assert.equal(sotceResponseAction("POST", "/sotce-net/touch-a-page", 200, { touchCreated: true }), "sotce_page_touched"); + for (const result of [{}, { touchCreated: false }, { touches: ["@someone"] }]) + assert.equal(sotceResponseAction("POST", "/sotce-net/touch-a-page", 200, result), null); + assert.equal(sotceResponseAction("POST", "/sotce-net/touch-a-page", 500, { touchCreated: true }), null); + assert.equal(sotceResponseAction("POST", "/sotce-net/ask", 200, { success: true, question: "never forwarded" }), "sotce_question_submitted"); + assert.equal(sotceResponseAction("POST", "/sotce-net/ask", 403, { success: true }), null); + assert.equal(sotceResponseAction("GET", "/sotce-net/asks", 200, { success: true }), null); +}); + +test("reading measures foreground display, skips editors and prefetch, and never emits page keys", () => { + let callback, now = 0, page = null, hidden = false; + const actions = []; + const classes = { contains: () => hidden }; + const doc = { visibilityState: "visible", body: { classList: classes }, documentElement: { classList: classes } }; + const win = { performance: { now: () => now }, acSotceVisiblePage: () => page, + acAccountActivity: { action: (...args) => actions.push(args) }, + setInterval: fn => { callback = fn; return 1; }, clearInterval: () => { callback = null; } }; + const api = startSotceActivity(win, doc); + assert.equal(startSotceActivity(win, doc), api); + const tick = (ms = 1000) => { now += ms; callback(); }; + for (let i = 0; i < 40; i++) tick(); + assert.equal(actions.length, 0, "no rendered page, no reading evidence"); + page = "private-page-id"; tick(); tick(); + assert.equal(actions.length, 0); + tick(); assert.deepEqual(actions, [["sotce_page_viewed"]]); + doc.visibilityState = "hidden"; + for (let i = 0; i < 40; i++) tick(); + doc.visibilityState = "visible"; tick(60000); + assert.equal(actions.length, 1, "background/sleep gaps are excluded"); + hidden = true; for (let i = 0; i < 40; i++) tick(); + hidden = false; tick(); for (let i = 0; i < 28; i++) tick(); + assert.deepEqual(actions, [["sotce_page_viewed"], ["sotce_page_visible_30s"]]); + page = "next-private-page"; tick(); tick(); tick(); + assert.equal(actions.at(-1)[0], "sotce_page_viewed"); + api.response("POST", "/sotce-net/ask", 200, { success: true, _id: "secret", question: "secret" }); + assert.deepEqual(actions.at(-1), ["sotce_question_submitted"]); + assert.doesNotMatch(JSON.stringify(actions), /secret|private/); + api.stop(); assert.equal(callback, null); +}); diff --git a/system/tests/sotce-route-fallback.test.mjs b/system/tests/sotce-route-fallback.test.mjs index 291418e675..1a83aeb3a1 100644 --- a/system/tests/sotce-route-fallback.test.mjs +++ b/system/tests/sotce-route-fallback.test.mjs @@ -15,7 +15,7 @@ const mocks = { ].map((name) => [name, "synthetic"])), "../../public/aesthetic.computer/lib/helpers.mjs": { defaultTemplateStringProcessor: (strings, ...values) => - strings.reduce((text, part, i) => text + part + (values[i] ?? ""), ""), + strings.reduce((text, part, i) => text + part + (i === strings.length - 1 ? "" : String(values[i])), ""), }, "../../backend/http.mjs": { respond: (statusCode, body, headers) => ({ statusCode, body, headers }), @@ -57,6 +57,19 @@ test("known static routes still return their own responses", async () => { assert.match(response.body, /addEventListener\("push"/); }); +test("generated Sotce browser modules compile with the activity hooks", async () => { + const response = await module.namespace.handler({ httpMethod: "GET", path: "/", headers: {} }); + assert.equal(response.statusCode, 200); + let modules = 0; + for (const [, attributes, source] of response.body.matchAll(/]*)>([\s\S]*?)<\/script>/g)) { + if (!source.trim() || /application\/ld\+json/.test(attributes)) continue; + if (/type=["']module["']/.test(attributes)) { new vm.SourceTextModule(source, { context }); modules++; } + else new vm.Script(source); + } + assert.ok(modules > 0); + assert.match(response.body, /sotce-activity\.mjs/); +}); + test("unsupported methods also return a response without external services", async () => { for (const httpMethod of ["POST", "HEAD", "OPTIONS"]) { diff --git a/toolchain/analytics/VISITS.md b/toolchain/analytics/VISITS.md index 7bd95fa968..1f39e37107 100644 --- a/toolchain/analytics/VISITS.md +++ b/toolchain/analytics/VISITS.md @@ -190,6 +190,26 @@ The private analytics MCP exposes: Both default to studio scope and accept `limit` (up to 500). No campaign tags are collected. Missing data before deployment cannot be reconstructed. +Use `account_activity({hours:24, property:"sotce.net"})` or +`network_referrers({hours:24, property:"sotce.net"})` to isolate Sotce; add +`handle` to follow a particular account with a resolvable public handle. + +Sotce's authenticated feed additionally records `sotce_page_viewed` after two +foreground display seconds and `sotce_page_visible_30s` after thirty. Only the +displayed, loaded card qualifies: prefetched pages, flipped backs, transitions, +editors and hidden tabs do not. Time gaps are capped at one second. Returning +to a page after viewing another can produce another milestone; no page key, +number or content leaves the browser through this feed. These indicate display, +not verified reading or unique pages. Canvas and virtualized DOM views are covered. + +`sotce_page_touched` requires a newly inserted touch (`touchCreated: true`), +excluding existing touches, the author's own page and failed writes. +`sotce_question_submitted` requires a successful saved question; it is the sole +allowed milestone within `/ask`, with no form content. `/comment`, `/chat`, +`/write` and `/respond` remain excluded. These four Sotce milestones can repeat +within a session and carry client sequence numbers. They remain best-effort +browser reports with server-verified identity; the existing `sotce-touches` +and `sotce-asks` collections are authoritative for saved operation totals. ### Website installation diff --git a/toolchain/analytics/journey-report.mjs b/toolchain/analytics/journey-report.mjs index d14263b064..840e5a7724 100644 --- a/toolchain/analytics/journey-report.mjs +++ b/toolchain/analytics/journey-report.mjs @@ -5,11 +5,16 @@ import { connect, closePool } from "../../system/backend/database.mjs"; import { visitScopeMatch, visitReferrer, visitProperty, visitSurface, automatedVisit } from "../../system/public/aesthetic.computer/lib/visit-model.mjs"; const mode = process.argv[2]; -const { hours = 24, limit = 100, handle, scope = "studio" } = JSON.parse(process.argv[3] || "{}"); +const { hours = 24, limit = 100, handle, scope = "studio", property } = JSON.parse(process.argv[3] || "{}"); if (!["accounts", "referrers"].includes(mode) || !Number.isFinite(hours) || hours <= 0 || hours > 840 || !Number.isInteger(limit) || limit < 1 || limit > 500 || (handle !== undefined && !/^@?[a-z0-9_-]{1,64}$/i.test(handle))) throw new Error("Invalid report options"); const scoped = visitScopeMatch(scope), end = new Date(), start = new Date(+end - hours * 3600000); +if (property !== undefined) { + const canonical = visitProperty(property); + if (!canonical || !scoped.property.$in.includes(canonical)) throw new Error("Property is outside the selected scope"); + scoped.property.$in = [canonical]; +} const { db } = await connect(); try { if (mode === "accounts") { diff --git a/toolchain/mcp/analytics-mcp.mjs b/toolchain/mcp/analytics-mcp.mjs index 076ebcf6a7..9796cbcede 100644 --- a/toolchain/mcp/analytics-mcp.mjs +++ b/toolchain/mcp/analytics-mcp.mjs @@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import zlib from "node:zlib"; import { serveStdio, serveHttp, httpPort } from "./http-front.mjs"; -import { VISIT_ACTIONS, VISIT_DEPTHS } from "../../system/public/aesthetic.computer/lib/visit-model.mjs"; +import { VISIT_ACTIONS, VISIT_DEPTHS, visitProperty, visitScopeMatch } from "../../system/public/aesthetic.computer/lib/visit-model.mjs"; import { fisheryOptions } from "../analytics/human-fishery.mjs"; const pexec = promisify(execFile); @@ -50,10 +50,11 @@ async function humanFishery(args = {}) { } async function journeyReport(mode, args = {}) { - const { hours = 24, limit = 100, scope = "studio", handle } = args; + const { hours = 24, limit = 100, scope = "studio", handle, property } = args; if (!Number.isFinite(hours) || hours <= 0 || hours > 840 || !Number.isInteger(limit) || limit < 1 || limit > 500 || !["studio", "clients", "all"].includes(scope) || (handle !== undefined && !/^@?[a-z0-9_-]{1,64}$/i.test(handle))) throw new Error("Invalid report options"); - const quoted = "'" + JSON.stringify({ hours, limit, scope, handle }).replaceAll("'", "'\\''") + "'"; + if (property !== undefined && !visitScopeMatch(scope).property.$in.includes(visitProperty(property))) throw new Error("Property is outside the selected scope"); + const quoted = "'" + JSON.stringify({ hours, limit, scope, handle, property }).replaceAll("'", "'\\''") + "'"; const remote = `cd /opt/ac/system && node --env-file=.env ../toolchain/analytics/journey-report.mjs ${mode} ${quoted}`; const { stdout } = await pexec("ssh", ["-i", SSH_KEY, "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", LITH, remote], { timeout: 30000, maxBuffer: 2 * 1024 * 1024 }); @@ -265,6 +266,7 @@ const TOOLS = [ hours: { type: "number", exclusiveMinimum: 0, maximum: 840, description: "Lookback hours; default 24" }, limit: { type: "integer", minimum: 1, maximum: 500, description: "Maximum rows; default 100" }, scope: { type: "string", enum: ["studio", "clients", "all"], description: "Default studio" }, + property: { type: "string", description: "Optional reviewed site, e.g. sotce.net; must belong to selected scope" }, ...(name === "account_activity" ? { handle: { type: "string", description: "Optional public handle, with or without @" } } : {}), } }, })), -- 2.51.2 From af853ae669dfab7f7f382ff4fab65f176362e29a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 30 Sep 2026 17:31:30 -0700 Subject: [PATCH 3/3] Track repeated Laklok feature use and report per-account activity trends --- system/public/aesthetic.computer/bios.mjs | 4 ++ .../public/aesthetic.computer/disks/chat.mjs | 14 ++++- .../aesthetic.computer/disks/laklok.mjs | 11 +++- .../lib/account-activity-model.mjs | 6 +- .../lib/account-activity.mjs | 23 +++++--- .../lib/laklok-activity.mjs | 22 ++++++++ system/public/html/index.html | 33 +++++++++-- system/public/network-privacy.html | 10 +++- system/tests/account-activity.test.mjs | 23 ++++++++ system/tests/journey-report.test.mjs | 27 ++++++++- system/tests/laklok-activity-browser.test.mjs | 56 +++++++++++++++++++ toolchain/analytics/VISITS.md | 32 +++++++++++ toolchain/analytics/journey-report.mjs | 11 ++-- toolchain/analytics/laklok-feature-report.mjs | 56 +++++++++++++++++++ toolchain/laklok-sisters/PARITY.md | 8 +++ toolchain/mcp/analytics-mcp.mjs | 15 +++-- 16 files changed, 322 insertions(+), 29 deletions(-) create mode 100644 system/public/aesthetic.computer/lib/laklok-activity.mjs create mode 100644 system/tests/laklok-activity-browser.test.mjs create mode 100644 toolchain/analytics/laklok-feature-report.mjs diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index 08a837017f..823de07783 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -18428,6 +18428,10 @@ async function boot(parsed, bpm = 60, resolution, debug) { return; } + if (type === "account:action") { + accountActivity.action(content?.action); + return; + } if (type === "visit:action") { window.acVisits?.action(content?.action); return; diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index 817f1f70ab..4580164afa 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -1045,6 +1045,7 @@ async function boot( token, sub: user.sub, }); + options?.onAction?.("message_edit_requested"); notice("EDITED"); } return; @@ -1054,7 +1055,11 @@ async function boot( // to start/stop the mini-player without reaching for the button. const radioCmd = parseRadioCommand(text); if (radioCmd) { - applyRadioCommand(radioCmd, send); + applyRadioCommand(radioCmd, message => { + send(message); + if (message.type === "stream:play") options?.onAction?.("radio_play_requested"); + if (message.type === "stream:pause") options?.onAction?.("radio_pause_requested"); + }); notice(radioCmd.toast || "", ["orange", 0]); return; } @@ -1103,6 +1108,7 @@ async function boot( sub: user.sub, font: userSelectedFont, // 🔤 Include selected font }); + options?.onAction?.("message_send_requested"); notice("SENT"); } } @@ -3395,7 +3401,11 @@ function act( pen.y >= r8dioPlayerBounds.y && pen.y < r8dioPlayerBounds.y + r8dioPlayerBounds.h; if (inBar) { beep(); - toggleR8dioPlayback(send); + toggleR8dioPlayback(message => { + send(message); + if (message.type === "stream:play") options?.onAction?.("radio_play_requested"); + if (message.type === "stream:pause") options?.onAction?.("radio_pause_requested"); + }); } } } diff --git a/system/public/aesthetic.computer/disks/laklok.mjs b/system/public/aesthetic.computer/disks/laklok.mjs index 8f0ab97ffb..e9d22e24bb 100644 --- a/system/public/aesthetic.computer/disks/laklok.mjs +++ b/system/public/aesthetic.computer/disks/laklok.mjs @@ -18,6 +18,7 @@ import { Chat } from "../lib/chat.mjs"; // TODO: Eventually expand to `net.Socket` import * as chat from "./chat.mjs"; // Import chat everywhere. +import { laklokAction } from "../lib/laklok-activity.mjs"; import { LAK_THEMES, realtimeTick, @@ -142,7 +143,7 @@ function chatView() { function boot({ api, wipe, debug, send, hud, store, colon, params, jump, net, query }) { client = new Chat(debug, send); client.connect("clock"); // Connect to 'clock' chat. (DB stays `chat-clock`.) - chat.boot(api, client.system); // Use default font + chat.boot(api, client.system, { onAction: name => laklokAction(api, name) }); // Use default font // 🚫 chat.boot stamps a prompt.ac/chat QR to the LEFT of the HUD label; clear // it so laklok shows only its own laklok.com QR in the top-right (paintCorner). @@ -393,12 +394,14 @@ function act($) { // so a tap on a chip never scrolls the chat underneath. if (e.is("touch") && hit(gearBox)) { settingsOpen = !settingsOpen; + if (settingsOpen) laklokAction($, "settings_opened"); needsPaint?.(); return; } // 📬 The envelope is the door to mail. if (e.is("touch") && !settingsOpen && hit(mailBox)) { + laklokAction($, "mail_open_requested"); jump("mail"); return; } @@ -413,19 +416,23 @@ function act($) { if (chip) { const { type, value } = chip.action; if (type === "mode" && value === "vector") { + laklokAction($, "mode_switch_requested"); jump("out:https://laklok.com/html/"); } else if (type === "theme") { + if (lakTheme !== value) laklokAction($, "theme_changed"); lakTheme = value; saveTema(store, value); if (value === "realtime") realtimeTick(); // catch up before first paint chat.refresh(client.system); // recolor cached message lines reportTema(net, value); } else if (type === "links") { + if (lakLinksOnly !== value) laklokAction($, "filter_changed"); lakLinksOnly = value; store["laklok:links"] = value; store.persist("laklok:links"); chat.refresh(client.system); // relayout the filtered feed } else if (type === "lang") { + if (lakLang !== value) laklokAction($, "language_changed"); lakLang = value; saveLang(store, value); } @@ -437,7 +444,7 @@ function act($) { return; } - chat.act($, chatView(), { allowDelete: true }); + chat.act($, chatView(), { allowDelete: true, onAction: name => laklokAction($, name) }); } function sim($) { diff --git a/system/public/aesthetic.computer/lib/account-activity-model.mjs b/system/public/aesthetic.computer/lib/account-activity-model.mjs index f266a60c03..3867114e3d 100644 --- a/system/public/aesthetic.computer/lib/account-activity-model.mjs +++ b/system/public/aesthetic.computer/lib/account-activity-model.mjs @@ -1,8 +1,9 @@ import { VISIT_ACTIONS, visitProperty, visitSurface, visitReferrer, visitGroup } from "./visit-model.mjs"; +import { LAKLOK_ACTIONS, LAKLOK_PIECES, LAKLOK_FEATURE_VERSION } from "./laklok-activity.mjs"; export const ACCOUNT_ACTIVITY_COLLECTION = "account-activity"; export const SOTCE_ACTIONS = Object.freeze(["sotce_page_viewed", "sotce_page_visible_30s", "sotce_page_touched", "sotce_question_submitted"]); -export const ACCOUNT_ACTIONS = Object.freeze(["piece_opened", ...VISIT_ACTIONS, ...SOTCE_ACTIONS]); +export const ACCOUNT_ACTIONS = Object.freeze(["piece_opened", ...VISIT_ACTIONS, ...SOTCE_ACTIONS, ...LAKLOK_ACTIONS]); export function accountActivityRoute(host, path, action) { if (visitSurface(path) === null) return false; @@ -31,7 +32,10 @@ export function validateAccountActivity(body, origin) { !/^[a-z0-9-]{1,64}$/.test(body.piece) || visitSurface(`/${body.piece}`) === null || typeof body.automated !== "boolean") return null; if (SOTCE_ACTIONS.includes(body.action) && (property !== "sotce.net" || body.piece !== "sotce")) return null; + const laklok = property !== "sotce.net" && LAKLOK_PIECES.includes(body.piece) && body.featureVersion === LAKLOK_FEATURE_VERSION; + if (LAKLOK_ACTIONS.includes(body.action) && !laklok) return null; return { id: body.id.toLowerCase(), session: body.session.toLowerCase(), sequence: body.sequence, property, + ...(laklok ? { featureVersion: LAKLOK_FEATURE_VERSION } : {}), tenant: property === "sotce.net" ? "sotce" : "aesthetic", piece: body.piece, action: body.action, automated: body.automated, referrerHost: typeof body.referrerHost === "string" ? visitReferrer(body.referrerHost) : null }; diff --git a/system/public/aesthetic.computer/lib/account-activity.mjs b/system/public/aesthetic.computer/lib/account-activity.mjs index 4d89fb9d67..d502ea478c 100644 --- a/system/public/aesthetic.computer/lib/account-activity.mjs +++ b/system/public/aesthetic.computer/lib/account-activity.mjs @@ -1,4 +1,5 @@ import { activityPiece, accountActivityRoute, SOTCE_ACTIONS } from "./account-activity-model.mjs"; +import { LAKLOK_ACTIONS, LAKLOK_PIECES, LAKLOK_FEATURE_VERSION } from "./laklok-activity.mjs"; import { automatedVisit, visitProperty, visitSurface, visitReferrer, VISIT_ACTIONS } from "./visit-model.mjs"; // Authenticated activity has its own short-lived session and endpoint. It does @@ -9,7 +10,7 @@ export function startAccountActivity(win = window, doc = document, { } = {}) { if (win.acAccountActivity) return win.acAccountActivity; let piece = null, ready = false, owner = null, session = null, generation = 0, sequence = 0, stopped = false; - let sent = new Set(), pending = new Set(), retryAt = 0; + let sent = new Set(), pending = new Set(), retryAt = 0, inFlight = 0; const allowed = action => !stopped && win === win.top && doc.visibilityState === "visible" && !win.acPACK_MODE && !win.acVisitTrackingDisabled && win.navigator.doNotTrack !== "1" && win.doNotTrack !== "1" && !win.navigator.globalPrivacyControl && @@ -24,13 +25,18 @@ export function startAccountActivity(win = window, doc = document, { } async function record(action) { syncOwner(); - const repeated = SOTCE_ACTIONS.includes(action); - if (repeated && (visitProperty(win.location.hostname) !== "sotce.net" || piece !== "sotce")) return; - if (!owner || !piece || !ready || !allowed(action) || (!repeated && sent.has(action)) || pending.has(action) || Date.now() < retryAt) return; + const laklok = LAKLOK_ACTIONS.includes(action); + const sotce = SOTCE_ACTIONS.includes(action); + const repeated = laklok || sotce; + if (sotce && (visitProperty(win.location.hostname) !== "sotce.net" || piece !== "sotce")) return; + if (laklok && !LAKLOK_PIECES.includes(piece)) return; + if (!owner || !piece || !ready || !allowed(action) || (!repeated && sent.has(action)) || + (!laklok && pending.has(action)) || inFlight >= 20 || Date.now() < retryAt) return; const epoch = generation, subject = owner, activeSession = session, activePiece = piece; const order = ++sequence; const activePending = pending, activeSent = sent; activePending.add(action); + inFlight++; let timeout; const controller = new AbortController(); try { @@ -38,15 +44,16 @@ export function startAccountActivity(win = window, doc = document, { const token = await Promise.race([Promise.resolve().then(getToken), expired]); if (!token || epoch !== generation || subject !== getUser()?.sub || !allowed(action)) return; const response = await win.fetch("https://aesthetic.computer/api/account-activity", { - method: "POST", credentials: "omit", referrerPolicy: "no-referrer", signal: controller.signal, + method: "POST", credentials: "omit", referrerPolicy: "no-referrer", signal: controller.signal, keepalive: true, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ version: 1, id: win.crypto.randomUUID(), session: activeSession, - sequence: order, piece: activePiece, action, automated: false, referrerHost: visitReferrer(doc.referrer) }), + sequence: order, piece: activePiece, action, automated: false, referrerHost: visitReferrer(doc.referrer), + ...(LAKLOK_PIECES.includes(activePiece) ? { featureVersion: LAKLOK_FEATURE_VERSION } : {}) }), }); if (response.ok) activeSent.add(action); else retryAt = Date.now() + 30000; } catch { retryAt = Date.now() + 30000; } - finally { win.clearTimeout(timeout); activePending.delete(action); } + finally { inFlight--; win.clearTimeout(timeout); activePending.delete(action); } } const timer = win.setInterval(() => { syncOwner(); @@ -60,7 +67,7 @@ export function startAccountActivity(win = window, doc = document, { const api = { load(path) { piece = activityPiece(path); ready = false; sent = new Set(); pending = new Set(); generation++; }, ready() { ready = true; void record("piece_opened"); }, - action(name) { if (VISIT_ACTIONS.includes(name) || SOTCE_ACTIONS.includes(name)) void record(name); }, + action(name) { if (VISIT_ACTIONS.includes(name) || SOTCE_ACTIONS.includes(name) || LAKLOK_ACTIONS.includes(name)) void record(name); }, stop() { stopped = true; generation++; win.clearInterval(timer); if (win.acAccountActivity === api) delete win.acAccountActivity; }, }; win.acAccountActivity = api; diff --git a/system/public/aesthetic.computer/lib/laklok-activity.mjs b/system/public/aesthetic.computer/lib/laklok-activity.mjs new file mode 100644 index 0000000000..f4680c67d3 --- /dev/null +++ b/system/public/aesthetic.computer/lib/laklok-activity.mjs @@ -0,0 +1,22 @@ +// Reviewed feature names only: no messages, links, recipients or preference values. +export const LAKLOK_FEATURE_VERSION = 1; +export const LAKLOK_PIECES = ["laklok", "laklok-vector"]; +export const LAKLOK_FEATURES = Object.freeze({ + laklok_settings_opened: ["raster", "vector"], + laklok_theme_changed: ["raster", "vector"], + laklok_filter_changed: ["raster", "vector"], + laklok_language_changed: ["raster"], + laklok_mode_switch_requested: ["raster", "vector"], + laklok_mail_open_requested: ["raster"], + laklok_radio_play_requested: ["raster"], + laklok_radio_pause_requested: ["raster"], + laklok_message_send_requested: ["raster", "vector"], + laklok_message_edit_requested: ["raster", "vector"], + laklok_history_older_requested: ["vector"], + laklok_media_open_requested: ["vector"], +}); +export const LAKLOK_ACTIONS = Object.freeze(Object.keys(LAKLOK_FEATURES)); +export function laklokAction(api, name) { + const action = `laklok_${name}`; + if (LAKLOK_ACTIONS.includes(action)) api.send?.({ type: "account:action", content: { action } }); +} diff --git a/system/public/html/index.html b/system/public/html/index.html index 03379f21b4..d44749a695 100644 --- a/system/public/html/index.html +++ b/system/public/html/index.html @@ -407,6 +407,8 @@