From 92ad83249bb09213538e9519aec0fba91725cfc4 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 30 Sep 2026 17:18:01 -0700 Subject: [PATCH] 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