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.
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(/