From 1d2358a7198e2c5927c4a0833962af9746d92beb Mon Sep 17 00:00:00 2001
From: "prompt.ac/@jeffrey"
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. +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.
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"
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.
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(/