openvibers/openvibe.report Commits
Report /me and public /u/<name>: your OpenVibe in numbers (#146)
Changes
@@@ -79,6 +79,8 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through|---|---|| `/` | the 7-day open numbers, the Rundown's counts and lead, and the trending topics || `/stats?days=7\|30\|90` | the network totals and a first look at every site (anything else is 30) |++| `/me?days=7\|30\|90` | **signed in only, private, noindex**: your sites and activity (Network's history), what you made, your AI and Vibes, with a switch per section to publish it (the owner, 2026-11-09) |++| `/u/:username` | a person's published stats: only the sections they turned on, with Person JSON-LD; noindex and no numbers until they publish || `/rundown` | on air, the lead, the latest streams and every topic; canonical is OpenVibe.Live's `/rundown` || `/rundown/t/:slug` | one topic, its takes and related topics; canonical is Live's `/rundown/t/:slug` || `/rundown/e/:streamId` | one stream as an episode, with its segments; canonical is Live's `/rundown/e/:streamId` |@@@ -111,10 +113,16 @@ GET /api/v1/rundown/t/:slugGET /api/v1/after-show/:streamIdGET /api/v1/c/:usernameGET /api/v1/ai/results the new AI runs, newest first++GET /api/v1/me/stats?days=7|30|90 the signed-in person's own numbers (private, no-store)++PUT /api/v1/me/stats/published the five per-section switches (same-origin, JSON)++GET /api/v1/u/:name/stats only the sections that person published (404 when none)```A bad parameter or a missing source answer is a 404 `application/problem+json` body built with--`openvibe-contracts` `http.sendProblem`. The public API is read-only; other methods get 405.++`openvibe-contracts` `http.sendProblem`. The API is read-only except for the person's own publish switches; other++methods get 405. `GET /api/v1/me/stats` and `PUT /api/v1/me/stats/published` answer the `report.me-stats@1` document++(the switch request is `report.me-stats-publish@1`); the public route copies only the published sections from the++person's last snapshot, so a section a source did not answer for is absent, never a zero.The AI Exchange JSON read is the staff exception (with `REPORT_AI_EXCHANGE=1`): a Network service token with thesame `report.ai_usage.read` capability the AI meter uses, never cached, no person or project in the answer. Thennew file mode 100644@@@ -0,0 +1,32 @@++-- phase: expand++-- A person's own numbers (the owner, 2026-11-09): "you can make them public if you want, but private by default."++--++-- One row per subject holds the five publish switches (published, all false until the person turns one on) and the++-- last computed snapshot (report.me-stats@1). The snapshot is the private document: the owner's /me, GET++-- /api/v1/me/stats and the per-section switches write it. The public /u/<name> page and GET /api/v1/u/:name/stats++-- read it through server/me-stats.js, which copies only the sections the switches name; the row itself is never++-- returned to a reader.++--++-- Why a snapshot and not a live count on the public page: the sites and the activity come from Network's cross-site++-- history, which only the person's own session token may read. A public page has no token, so it shows the snapshot++-- the owner last computed, with its generated_at. A section whose source did not answer is absent from the snapshot,++-- never a zero.++--++-- The display name and avatar are stored so a public page still names the person when the snapshot is old. Usernames++-- change on Network: the row is re-keyed by subject on every visit and matched case-insensitively by the current name.++--++-- Expand only: one table and its indexes; nothing that exists is changed or dropped, and an older release keeps++-- reading its own tables exactly as before.++CREATE TABLE IF NOT EXISTS report_me_stats (++ subject text COLLATE "C" PRIMARY KEY,++ username text NOT NULL,++ display_name text NOT NULL,++ avatar_url text,++ published jsonb NOT NULL DEFAULT '{}'::jsonb,++ snapshot jsonb,++ snapshot_at timestamptz,++ updated_at timestamptz NOT NULL DEFAULT now()++);++++CREATE UNIQUE INDEX IF NOT EXISTS report_me_stats_username ON report_me_stats (lower(username));++CREATE INDEX IF NOT EXISTS report_me_stats_updated ON report_me_stats (updated_at DESC);@@@ -13,7 +13,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.362.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",@@@ -1057,9 +1057,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.338.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",-- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",++ "version": "0.362.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.362.0",++ "integrity": "sha512-Dx+wnwTVSA90fZsjnCv8VAF3G6PI+vgFO8roRhKkAQujnKy6Ee82w9xcU07k0QbDYjXvJuVxw+VCnxMNNhT3Uw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -19,7 +19,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.362.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",@@@ -386,3 +386,49 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex/* A table inside a kit section (the AI page's leaders and benchmarks): the section is the card, so the table's ownframe goes (it drew a card inside a card, one border inside another). */.ovk-section > .ovk-table-wrap { border: 0; border-radius: 0; background: transparent; box-shadow: none; }++++/* Your stats (designed 11-09): sites as bars, twelve weeks as a heatmap, sharing as switches after the numbers. */++.rp-sites { list-style: none; max-width: 760px; margin: 4px 0 10px; padding: 0; display: grid; gap: 2px; }++.rp-sites li { display: grid; grid-template-columns: minmax(84px, 9rem) minmax(0, 1fr) auto; grid-template-rows: auto auto; align-items: center; column-gap: 12px; padding: 8px 4px; border-radius: 10px; }++.rp-sites li > a { grid-row: 1 / 3; font-weight: 700; color: inherit; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.rp-sites li > a:hover { color: var(--accent, #3b82f6); }++.rp-sbar { display: block; height: 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); overflow: hidden; }++.rp-sbar i { display: block; width: var(--p); height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent-strong, #2563eb), var(--accent, #3b82f6)); transform-origin: left; animation: rp-grow .8s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i, 0) * 50ms); }++.rp-sites li > b { font-size: 1.05rem; font-variant-numeric: tabular-nums; text-align: right; }++.rp-sites li > small { grid-column: 2 / 4; font-size: .78rem; color: var(--text-muted, #7386a3); }++@keyframes rp-grow { from { transform: scaleX(0); } }++.rp-heat { display: flex; gap: 6px; margin: 18px 0 6px; overflow-x: auto; }++.rp-heat-days, .rp-heat-col { display: grid; grid-template-rows: repeat(7, 1fr); gap: 4px; }++.rp-heat-days i { font-style: normal; font-size: .66rem; line-height: 1; color: var(--text-muted, #7386a3); display: flex; align-items: center; min-height: 0; }++.rp-heat-grid { flex: 0 1 auto; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(14px, 24px); gap: 4px; }++.rp-heat-col i, .rp-heat-scale i { display: block; aspect-ratio: 1; min-width: 12px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }++.rp-heat-col i.l1, .rp-heat-scale i.l1 { background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }++.rp-heat-col i.l2, .rp-heat-scale i.l2 { background: color-mix(in srgb, var(--accent, #3b82f6) 52%, transparent); }++.rp-heat-col i.l3, .rp-heat-scale i.l3 { background: color-mix(in srgb, var(--accent, #3b82f6) 76%, transparent); }++.rp-heat-col i.l4, .rp-heat-scale i.l4 { background: var(--accent, #3b82f6); }++.rp-heat-key { display: flex; justify-content: space-between; align-items: center; gap: 10px; max-width: 360px; margin: 0 0 14px 34px; font-size: .74rem; color: var(--text-muted, #7386a3); }++.rp-heat-scale { display: inline-flex; align-items: center; gap: 3px; }++.rp-heat-scale i { width: 11px; min-width: 11px; }++.rp-fold { margin: 6px 0 4px; }++.rp-fold > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; font-weight: 700; color: var(--text-secondary, #96a7c2); }++.rp-fold > summary span { font-weight: 600; color: var(--text-muted, #7386a3); font-size: .85rem; }++.rp-privacy { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; width: fit-content; margin: 4px 0 18px; padding: 8px 14px; border-radius: 999px; font-size: .88rem; font-weight: 650;++ background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }++.rp-privacy span::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: var(--text-muted, #7386a3); vertical-align: 1px; }++.rp-privacy.is-public span::before { background: var(--success, #22c55e); }++.rp-privacy a { color: var(--accent, #3b82f6); }++.rp-share fieldset { margin: 0; padding: 0; border: 0; }++.rp-share ul { list-style: none; margin: 0 0 14px; padding: 0; display: grid; gap: 4px; }++.rp-share .rp-publish-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 2px 4px; border-radius: 12px; }++.rp-share .rp-publish-row label { flex: 1; display: flex; align-items: center; gap: 12px; min-height: 48px; cursor: pointer; }++.rp-share .rp-publish-row > .rp-fine { margin: 0; flex: none; font-size: .8rem; }++.rp-switch { appearance: none; -webkit-appearance: none; flex: none; position: relative; width: 44px; height: 26px; margin: 0; border-radius: 999px; cursor: pointer;++ background: color-mix(in srgb, var(--text-primary, #e6edf7) 16%, transparent); transition: background .2s; }++.rp-switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .2s cubic-bezier(.2, .9, .3, 1.2); }++.rp-switch:checked { background: var(--success, #22c55e); }++.rp-switch:checked::after { transform: translateX(18px); }++.rp-switch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++.rp-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++@media (max-width: 520px) { .rp-sites li { grid-template-columns: minmax(70px, 6.5rem) minmax(0, 1fr) auto; column-gap: 10px; } }++@media (prefers-reduced-motion: reduce) { .rp-sbar i { animation: none; } .rp-switch, .rp-switch::after { transition: none; } }++.rp-share-card { margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--border, rgba(255,255,255,.1)); }@@@ -7,6 +7,8 @@* /, /stats, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username,* /after-show/:streamId, /c/:username the pages (http/pages.js)* /you your own numbers, signed in (plan L41 R1)++ * /me, /u/:username a person's stats: private by default, the published++ * sections at /u (the owner, 2026-11-09)* /auth/* sign-in with OpenVibe.Network (openvibe-sdk/sso)* /ai, /ai/models/:model, /ai/:kind the AI boards, from OpenVibe.Bench (http/pages.js)* /api/v1/* the JSON API (http/api.js)@@@ -16,7 +18,8 @@* Report has its own database (server/db.js, migrations/) for the analytics it ingests, and reads Billing's money* through Billing's read API. The pages and GET /api/v1/stats answer from REPORT_SOURCE (network by default,* report after cutover); Report's tables are written by the ingest job and by OpenVibe.Events. The only session is-- * the person's own sign-in (openvibe-sdk/sso, the `report` client): it reads /you and nothing else.++ * the person's own sign-in (openvibe-sdk/sso, the `report` client): it reads /you, /me and the /u/<name> page a++ * person chose to publish; the only write is the per-section publish switch (PUT/POST /api/v1/me/stats/published).*/const path = require('path');const express = require('express');@@@ -38,6 +41,7 @@ const { createAiExchange } = require('./analytics/ai-exchange');const createAiExchangeConfig = require('./ai-exchange-config').create;const { createSearchIndex } = require('./search-index');const { createSessionReports } = require('./session-reports');++const { createMeStats } = require('./me-stats');const { createSessionReportRoutes } = require('./http/session-reports');const sharing = require('./sharing');const { createShadow } = require('./analytics/shadow');@@@ -102,6 +106,10 @@ async function createApp(opts = {}) {const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);// The report of every agent session (Agent Streams S3): one table, one route, one page.const sessionReports = opts.sessionReports || (store ? createSessionReports({ store, config, log }) : null);++ // A person's own numbers (the owner, 2026-11-09): the five sections and the per-section publish switches. The++ // snapshot is stored here; the public page reads only what the person published. With no store, /me computes but++ // nothing can be published.++ const meStats = opts.meStats || createMeStats({ store, network, analytics, aiUsage, sessionReports, config, now, log });// The sharing system (openvibe-sdk/share, share.v1): a report's four audiences live in its share row, the owner's// Share sheet changes it, and every read path asks canView (server/sharing.js). Built before the session-report// writer, so the first write registers its row.@@@ -118,7 +126,7 @@ async function createApp(opts = {}) {secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,});-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports };++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports, meStats };const app = express();if (exchangeConfig) exchangeConfig.mount(app);// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts@@@ -224,6 +232,28 @@ async function createApp(opts = {}) {// pinned openvibe-sdk predates `share`: then only the old column's three levels work, and a report nobody may open// is a 404 — fail closed.app.use(shareRouterFor({ log }));++ // The publish switches as a plain form (the /me page's "Make my stats public" rows), so the page works whole++ // without JavaScript. Same origin only; signed in only; answers /me again with the new switches in force. The JSON++ // PUT for apps and the menu lives in the read API below.++ app.post('/api/v1/me/stats/published', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ const user = req.user;++ if (!user || !user.subject_id) return res.redirect(303, '/auth/login?next=%2Fme');++ if (!req.headers['x-requested-with'] && !['same-origin', 'same-site'].includes(String(req.headers['sec-fetch-site'] || ''))) {++ return contracts.http.sendProblem(res, 403, 'report.csrf', { detail: 'Cross-site request refused', ctx: req.ov });++ }++ const published = {};++ for (const key of meStats.SECTIONS) {++ const raw = req.body && req.body[key];++ published[key] = (Array.isArray(raw) ? raw[raw.length - 1] : raw) === 'true';++ }++ try {++ await meStats.setPublished({ user, token: req.token, published });++ } catch (err) {++ log.error('[OpenVibe.Report] the stats switches failed:', err && err.message ? String(err.message).slice(0, 200) : err);++ }++ return res.redirect(303, '/me');++ });// The owner's default for the reports they make from now on (their own page, /you). Signed in only, same-origin// only, and it changes nothing about a report that already exists — the sheet changes those one by one.app.post('/api/v1/me/session-reports/default', express.json({ limit: '2kb' }), async (req, res) => {@@@ -289,7 +319,7 @@ async function createApp(opts = {}) {legacyHeaders: false,handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),}));-- app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, config, now, log }));++ app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, meStats, config, now, log }));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));// ── Crawl artifacts, then the pages ─────────────────────@@@ -30,7 +30,23 @@ function createNetworkClient({ config = {}, fetchImpl = globalThis.fetch, log =return cache.set(window, data, STATS_TTL_MS);}-- return { stats, cache, baseUrl, ttlMs: STATS_TTL_MS };++ /**++ * The person's own cross-site history (GET /api/history), with their own Network session token. Report's sign-in++ * token is a Network user session (openvibe-sdk/sso, audience openvibe.network + report), so Network answers it.++ * Returns the rows, or null when Network cannot answer. Never cached: it is one person's and changes.++ */++ async function history(token, { limit = 200 } = {}) {++ const bearer = typeof token === 'string' ? token.trim() : '';++ if (!bearer) return null;++ const cap = Math.max(1, Math.min(200, Number(limit) || 200));++ const data = await requestJson(`${baseUrl}/api/history?limit=${cap}`, {++ fetchImpl, log, timeoutMs, label: 'network history', headers: { Authorization: `Bearer ${bearer}` },++ });++ if (!data || !Array.isArray(data.items)) return null;++ return data.items;++ }++++ return { stats, history, cache, baseUrl, ttlMs: STATS_TTL_MS };}module.exports = { createNetworkClient, STATS_TTL_MS };@@@ -31,15 +31,16 @@ function warn(log, message) {/*** GET a JSON document with an 8 s timeout. Returns the parsed body, or null after one log line.-- * A 404 is the source answering "no such thing", so it is null without a log line.++ * A 404 is the source answering "no such thing", so it is null without a log line. `headers` carries a caller's++ * own Authorization for the routes that answer one person (Network's history); it is never logged.*/--async function requestJson(url, { fetchImpl = globalThis.fetch, log = console, label = 'source', timeoutMs = TIMEOUT_MS } = {}) {++async function requestJson(url, { fetchImpl = globalThis.fetch, log = console, label = 'source', timeoutMs = TIMEOUT_MS, headers = null } = {}) {let res;try {const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'? AbortSignal.timeout(timeoutMs): undefined;-- res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });++ res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(headers || {}) }, signal });} catch (err) {warn(log, `${label} failed: ${reasonOf(err)}`);return null;@@@ -15,8 +15,9 @@* that answered nothing. Every /api/v1 answer is CORS-open and public for two minutes; the app mounts the 120/min* per-address limit in front of this router.*-- * GET /api/v1/me/ai/usage is the exception: it answers only the person their session names, so it is private, never-- * cached, and has its own tighter per-address limit.++ * GET /api/v1/me/ai/usage and GET /api/v1/me/stats answer only the person their session names, so they are private,++ * never cached, and have their own tighter per-address limit. PUT /api/v1/me/stats/published is the one write; GET++ * /api/v1/u/:name/stats is public and carries only the sections that person published.*/const express = require('express');const rateLimit = require('express-rate-limit');@@@ -30,17 +31,33 @@ const sharing = require('../sharing');const CACHE = 'public, max-age=120';--function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, config = {}, now = () => Date.now(), log = console }) {++function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, meStats = null, config = {}, now = () => Date.now(), log = console }) {const r = express.Router();const serviceAuth = createServiceAuth({ config, now });++ // The one write: the person's own publish switches. A browser sends a same-origin request (or an++ // X-Requested-With); a Bearer caller is not a cookie, so it needs no CSRF token. Everything else is read-only.++ const isPublishWrite = (req) => req.method === 'PUT' && (req.path === '/me/stats/published' || req.path === '/me/stats/published/');++ function sameOrigin(req) {++ if (String((req.headers && req.headers.authorization) || '').startsWith('Bearer ')) return true;++ const site = String((req.headers && req.headers['sec-fetch-site']) || '');++ return req.headers['x-requested-with'] === 'fetch' || site === 'same-origin' || site === 'same-site';++ }++r.use((req, res, next) => {res.set('Access-Control-Allow-Origin', '*');res.set('Cache-Control', CACHE);-- if (req.method !== 'GET' && req.method !== 'HEAD') {++ if (req.method === 'OPTIONS' && req.path === '/me/stats/published') {++ res.set('Access-Control-Allow-Methods', 'GET, HEAD, PUT, OPTIONS');++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');++ return res.status(204).end();++ }++ if (req.method !== 'GET' && req.method !== 'HEAD' && !isPublishWrite(req)) {res.set('Allow', 'GET, HEAD');return problem(req, res, 405, 'method.not_allowed', 'This API is read-only; use GET.');}++ res.set('Access-Control-Allow-Methods', 'GET, HEAD, PUT, OPTIONS');++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');return next();});@@@ -215,6 +232,66 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analyticsreturn res.json(recaps);});++ // ── A person's own numbers (the owner, 2026-11-09) ─────────────────────────────────────────────────++ //++ // GET /api/v1/me/stats answers the person the session names, with every section a source answered for. The++ // per-section switches are the person's own; a section is absent until a source gives it, never a zero. A public++ // reader gets only what the person published, from their last snapshot (GET /api/v1/u/:name/stats).++ r.get('/me/stats', async (req, res) => {++ const user = req.user;++ if (!user || !user.subject_id) return problem(req, res, 401, 'auth.sign_in_required', 'Sign in to see your own numbers.');++ if (!meStats) return problem(req, res, 503, 'report.no_me_stats', 'Your numbers are not available in this process.');++ try {++ const body = await meStats.compute({ user, token: req.token, days: normaliseDays(req.query.days) });++ return res.json(body);++ } catch (err) {++ log.error('[OpenVibe.Report] the personal stats failed:', err && err.message ? String(err.message).slice(0, 200) : err);++ return problem(req, res, 500, 'report.me_stats_failed', 'Your numbers could not be read.');++ }++ });++++ // The per-section switch. It is the one route that changes something: it writes only this subject's row and++ // answers the recomputed document, so the page can show what is public now.++ r.put('/me/stats/published', express.json({ limit: '4kb' }), async (req, res) => {++ const user = req.user;++ if (!user || !user.subject_id) return problem(req, res, 401, 'auth.sign_in_required', 'Sign in to publish your numbers.');++ if (!sameOrigin(req)) return problem(req, res, 403, 'report.csrf', 'Cross-site request refused.');++ if (!meStats) return problem(req, res, 503, 'report.no_me_stats', 'Your numbers are not available in this process.');++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ const published = body.published;++ if (!published || typeof published !== 'object' || Array.isArray(published)) {++ return problem(req, res, 422, 'report.me_stats_invalid', 'published must name every section.');++ }++ const switches = {};++ for (const key of meStats.SECTIONS) {++ if (typeof published[key] !== 'boolean') return problem(req, res, 422, 'report.me_stats_invalid', `published.${key} must be true or false.`);++ switches[key] = published[key];++ }++ try {++ const out = await meStats.setPublished({ user, token: req.token, days: normaliseDays(body.days), published: switches });++ return res.json(out);++ } catch (err) {++ log.error('[OpenVibe.Report] the stats switches failed:', err && err.message ? String(err.message).slice(0, 200) : err);++ return problem(req, res, 500, 'report.me_stats_failed', 'Your public settings could not be saved.');++ }++ });++++ // A person's published numbers. Only the sections they published are here; a person who has published nothing is++ // a 404 to a stranger, exactly as if they had never made a page. The answer is the owner's last snapshot.++ r.get('/u/:name/stats', async (req, res) => {++ const name = String(req.params.name || '');++ if (!isUsername(name)) return notFound(req, res, 'report.person_stats_not_found', 'No such person.');++ if (!meStats) return problem(req, res, 503, 'report.no_me_stats', 'Public numbers are not available in this process.');++ try {++ const body = await meStats.publicFor(name);++ if (!body || !meStats.anyPublished(body.published)) return notFound(req, res, 'report.person_stats_not_found', 'No public numbers for that person.');++ return res.json(body);++ } catch (err) {++ log.error('[OpenVibe.Report] the public stats failed:', err && err.message ? String(err.message).slice(0, 200) : err);++ return problem(req, res, 500, 'report.me_stats_failed', 'Those numbers could not be read.');++ }++ });++// The caller's own session reports of the last days (Today's "Your agents this week", S7). The list is the// person's alone: a signed-out reader is 401, never a list of everybody's public reports.r.get('/me/session-reports', async (req, res) => {@@@ -32,7 +32,7 @@ function siteUpdated() {}}--function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {++function createDiscoveryRoutes({ config, live, bench, sessionReports = null, meStats = null }) {const r = express.Router();const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');const abs = (p) => `${site}${p}`;@@@ -46,8 +46,9 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {});r.get('/sitemap.xml', async (_req, res) => {-- const [ai, overview, reports] = await Promise.all([++ const [ai, overview, reports, people] = await Promise.all([bench.kinds(), bench.overview(), sessionReports ? sessionReports.listPublic({ limit: 500 }) : [],++ meStats ? meStats.listPublished({ limit: 500 }).catch(() => []) : [],]);const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];const o = overview && overview.data ? overview.data : {};@@@ -67,6 +68,12 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {{ loc: abs('/ai/method'), changefreq: 'weekly', priority: 0.6 },{ loc: abs('/ai/compare'), changefreq: 'weekly', priority: 0.6 },...reports.map((rep) => ({ loc: abs('/s/' + encodeURIComponent(rep.id)), changefreq: 'daily', priority: 0.6 })),++ // A person's public stats page is crawlable only when they published a section; the private default is++ // never in the sitemap. The last modification is the snapshot they last computed.++ ...people.filter((p) => p && p.username).map((p) => ({++ loc: abs(`/u/${encodeURIComponent(p.username)}`), changefreq: 'weekly', priority: 0.5,++ ...(dayOf(p.generated_at) ? { lastmod: dayOf(p.generated_at) } : {}),++ })),...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,})),@@@ -416,6 +416,32 @@ function createPageRoutes(ctx) {return send(req, res, views.recap({ recap }));});++ // A person's stats (the owner, 2026-11-09): /me is private and signed in only, with a switch per section; /u/<name>++ // is the same document with only the sections the person made public. Both are server-rendered, so the words and++ // the numbers are crawlable when published; /me is never indexed. The public page is the owner's last snapshot,++ // dated in the page, because Network's history can only be read with the person's own token.++ r.get('/me', async (req, res) => {++ const user = req.user;++ if (!user || !isUsername(String(user.username || ''))) return send(req, res, views.meSignedOut(), 200, 'private, no-store');++ const days = normaliseDays(req.query.days);++ try {++ const doc = await ctx.meStats.compute({ user, token: req.token, days });++ return send(req, res, views.me({ user, doc, days }), 200, 'private, no-store');++ } catch (err) {++ ctx.log.error('[OpenVibe.Report] the /me page failed:', err && err.message ? String(err.message).slice(0, 200) : err);++ return send(req, res, views.meError({ user }), 500, 'private, no-store');++ }++ });++++ r.get('/u/:username', async (req, res) => {++ const { username } = req.params;++ if (!isUsername(username)) return notFound(req, res);++ const doc = ctx.meStats ? await ctx.meStats.publicFor(username) : null;++ if (!doc) return notFound(req, res);++ const published = ctx.meStats.anyPublished(doc.published);++ return send(req, res, published ? views.publicStats({ doc }) : views.publicStatsPrivate({ doc }), 200, published ? CACHE : 'private, no-store');++ });++// A person's own numbers (plan L41 R1). Signed out: what it is and a sign-in. Signed in: their channel's streams and// after-show reports, the payments Billing says reached them, and The Rundown, over 7, 30 or 90 days. Private.r.get('/you', async (req, res) => {nnew file mode 100644@@@ -0,0 +1,339 @@++'use strict';++++/**++ * A person's own numbers (the owner, 2026-11-09): "a section for most used, your most used platforms across all of++ * them, with OpenVibe.Report analysis for you… show your stats… you can make them public if you want, but private by++ * default… full stats and graphs… and it shows most used in here."++ *++ * The sections, each read from the source that owns the number:++ * sites Network's cross-site history (GET /api/history with the person's own session token): the sites they++ * opened this week and this month, most visited first, and the top eight for the menu.++ * activity the same history as days: twelve weeks, the days they were around, and the streak.++ * made Report's own Live analytics (streams and hours) and the person's session reports.++ * ai Report's AI meter (report.ai-usage-me-result@2): calls, tokens and what the meter says they cost.++ * money Billing's read API, through the analytics client: the Vibes that reached the person.++ *++ * A source that does not answer leaves its section out; it is never turned into a zero. The document is stored as a++ * snapshot at /me: the public /u/<name> page and GET /api/v1/u/:name/stats serve that snapshot with only the sections++ * the person published. Nothing unpublished is ever put in a public answer.++ *++ * Network's history counts visits, not durations: the same page opened twice within ten minutes is one visit, and++ * there is no minutes field to invent. The contract says so too (report.me-stats@1).++ */++const DAY = 86_400_000;++const SECTIONS = ['sites', 'activity', 'made', 'ai', 'money'];++const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;++const SERVICE_RE = /^[a-z0-9-]{1,32}$/;++const HEAT_WEEKS = 12;++const TOP_SITES = 8;++const HISTORY_LIMIT = 200;++++const n = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));++const int = (v) => { const x = n(v); return x == null ? null : Math.max(0, Math.round(x)); };++const iso = (ms) => new Date(ms).toISOString();++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);++const startOfDay = (ms) => Date.UTC(new Date(ms).getUTCFullYear(), new Date(ms).getUTCMonth(), new Date(ms).getUTCDate());++++/** A history timestamp, which Network may send as SQLite's "YYYY-MM-DD HH:MM:SS" (UTC) or as ISO. */++function timeOf(value) {++ if (value == null) return null;++ if (typeof value === 'number') return Number.isFinite(value) ? value : null;++ const s = String(value);++ const withZone = /[TZ]/.test(s) || !s.includes(' ') ? s : `${s.replace(' ', 'T')}Z`;++ const t = Date.parse(withZone);++ return Number.isFinite(t) ? t : null;++}++++function emptyPublished() {++ return { sites: false, activity: false, made: false, ai: false, money: false };++}++++/** The switches, always all five and always booleans. */++function publishedOf(value) {++ const out = emptyPublished();++ if (!value || typeof value !== 'object') return out;++ for (const k of SECTIONS) out[k] = value[k] === true || value[k] === 'true';++ return out;++}++++const anyPublished = (p) => SECTIONS.some((k) => p[k] === true);++++function jsonOf(value) {++ if (value == null) return null;++ if (typeof value === 'object') return value;++ try { return JSON.parse(String(value)); } catch { return null; }++}++++/** A public http(s) URL, or null. */++function urlOf(value) {++ try {++ const u = new URL(String(value));++ return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null;++ } catch { return null; }++}++++function personOf(user) {++ const username = String((user && user.username) || '').trim();++ if (!USERNAME_RE.test(username)) return null;++ const person = { username, display_name: String((user && (user.display_name || user.username)) || username).slice(0, 80) };++ const avatar = urlOf(user && user.avatar_url);++ if (avatar) person.avatar_url = avatar;++ return person;++}++++function usernameOf(user) {++ const u = String((user && user.username) || '').trim();++ return USERNAME_RE.test(u) ? u : null;++}++++/**++ * The sites and the days from Network's history rows. Answers null when the history is unavailable, so the caller++ * leaves both sections out rather than claiming nothing happened.++ */++function fromHistory(items, now, days) {++ if (!Array.isArray(items)) return null;++ const today = startOfDay(now);++ const from = today - (Math.max(1, days) - 1) * DAY;++ const weekStart = today - 6 * DAY;++ const monthStart = today - 29 * DAY;++ const heatStart = today - (HEAT_WEEKS * 7 - 1) * DAY;++ const countFrom = Math.min(heatStart, from);++++ const sites = new Map();++ const byDay = new Map();++ for (const item of items) {++ const at = timeOf(item && (item.updated_at || item.created_at));++ if (at == null) continue;++ const service = String((item && item.service) || '').trim().toLowerCase();++ if (!SERVICE_RE.test(service)) continue;++ const url = urlOf(item && item.url);++ let origin = url ? new URL(url).origin + '/' : null;++ if (!origin) continue;++ if (at >= countFrom) {++ const day = dayOf(at);++ byDay.set(day, (byDay.get(day) || 0) + 1);++ }++ if (at < monthStart) continue;++ const hit = sites.get(service) || { service, label: '', url: origin, week: 0, month: 0, last: 0 };++ hit.month += 1;++ if (at >= weekStart) hit.week += 1;++ if (at >= hit.last) {++ hit.last = at;++ hit.url = origin;++ const label = String((item && item.service_label) || service);++ hit.label = label.length > 60 ? label.slice(0, 60) : label;++ }++ sites.set(service, hit);++ }++++ const all = [...sites.values()].sort((a, b) => b.month - a.month || a.service.localeCompare(b.service));++ const week = all.filter((s) => s.week > 0).sort((a, b) => b.week - a.week || a.service.localeCompare(b.service)).map((s) => ({ service: s.service, label: s.label || s.service, url: s.url, visits: s.week }));++ const month = all.map((s) => ({ service: s.service, label: s.label || s.service, url: s.url, visits: s.month }));++ const top = month.slice(0, TOP_SITES);++++ // Twelve weeks, oldest first; each starts on its Monday (UTC).++ const monday = today - ((new Date(today).getUTCDay() + 6) % 7) * DAY;++ const weeks = [];++ for (let w = HEAT_WEEKS - 1; w >= 0; w--) {++ const start = monday - w * 7 * DAY;++ const weekDays = [];++ for (let d = 0; d < 7; d++) weekDays.push(byDay.get(dayOf(start + d * DAY)) || 0);++ weeks.push({ week_start: dayOf(start), days: weekDays });++ }++ let activeDays = 0;++ for (let t = from; t <= today; t += DAY) if (byDay.get(dayOf(t))) activeDays += 1;++ let streak = 0;++ for (let t = today; t >= from; t -= DAY) {++ if (!byDay.get(dayOf(t))) break;++ streak += 1;++ }++ return {++ sites: { week, month, top },++ activity: { weeks, active_days: activeDays, streak_days: streak },++ };++}++++/** One session report's creation time, or null. */++function reportAt(report) {++ return timeOf(report && (report.created_at || report.createdAt));++}++++/**++ * The client. `store` may be null (a network-only Report): /me still computes, but nothing is published and /u++ * cannot answer. The network client, analytics, AI meter and session reports are the app's own.++ */++function createMeStats({ store = null, network = null, analytics = null, aiUsage = null, sessionReports = null, config = {}, now = () => Date.now(), log = console } = {}) {++ const db = store && store.db ? store.db : null;++ const clock = typeof now === 'function' ? now : () => Date.now();++ const liveBase = String((config && config.livePublicUrl) || 'https://openvibe.live').replace(/\/+$/, '');++ const base = String((config && config.baseUrl) || 'https://openvibe.report').replace(/\/+$/, '');++++ async function rowFor(subject) {++ if (!db || !subject) return null;++ try { return await db.prepare('SELECT * FROM report_me_stats WHERE subject = ?').get(String(subject)); } catch { return null; }++ }++++ async function switchesFor(subject) {++ const row = await rowFor(subject);++ return publishedOf(row && jsonOf(row.published));++ }++++ async function save({ subject, person, published, document }) {++ if (!db || !subject) return;++ await db.prepare(`INSERT INTO report_me_stats (subject, username, display_name, avatar_url, published, snapshot, snapshot_at, updated_at)++ VALUES (?, ?, ?, ?, ?::jsonb, ?::jsonb, now(), now())++ ON CONFLICT (subject) DO UPDATE SET++ username = excluded.username, display_name = excluded.display_name, avatar_url = excluded.avatar_url,++ published = excluded.published, snapshot = excluded.snapshot, snapshot_at = excluded.snapshot_at,++ updated_at = now()`)++ .run(String(subject), person.username, person.display_name, person.avatar_url || null, JSON.stringify(published), JSON.stringify(document));++ }++++ /** Compute one person's document. `token` is their own Network session, forwarded only to Network's history. */++ async function compute({ user, token = null, days = 30 } = {}) {++ const person = personOf(user);++ const subject = user && typeof user.subject_id === 'string' ? user.subject_id.trim() : '';++ if (!person || !subject) throw new Error('me-stats: a signed-in person is required');++ const windowDays = [7, 30, 90].includes(Number(days)) ? Number(days) : 30;++ const at = clock();++ const from = dayOf(at - (windowDays - 1) * DAY);++ const published = await switchesFor(subject);++++ const notes = [];++ const [history, stats, ai, reports] = await Promise.all([++ network && typeof network.history === 'function' && token ? network.history(token, { limit: HISTORY_LIMIT }).catch(() => null) : Promise.resolve(null),++ analytics && typeof analytics.channel === 'function' ? analytics.channel(person.username, windowDays).catch(() => null) : Promise.resolve(null),++ aiUsage && typeof aiUsage.forPerson === 'function' ? aiUsage.forPerson({ subject, from, group: 'day' }).catch(() => null) : Promise.resolve(null),++ sessionReports && typeof sessionReports.listForOwner === 'function' ? sessionReports.listForOwner(subject, { limit: 200 }).catch(() => null) : Promise.resolve(null),++ ]);++ if (network && typeof network.history === 'function' && token && history == null) notes.push('Your places and activity are not in this snapshot: OpenVibe.Network did not answer with your history.');++ if (Array.isArray(history) && history.length >= HISTORY_LIMIT) notes.push('Your places and activity count the latest 200 pages in Network’s history.');++ if (analytics && stats == null) notes.push('Your streams and Vibes are not in this snapshot: Report’s analytics did not answer.');++++ const sections = {};++ const hist = fromHistory(history, at, windowDays);++ if (hist) {++ sections.sites = hist.sites;++ sections.activity = hist.activity;++ }++++ const totals = stats && stats.totals && typeof stats.totals === 'object' ? stats.totals : null;++ const made = [];++ if (totals) {++ const streams = int(totals.streams);++ const seconds = n(totals.stream_seconds);++ if (streams > 0) made.push({ key: 'streams', label: 'Streams', count: streams, unit: 'streams', href: `${liveBase}/@${encodeURIComponent(person.username)}` });++ if (seconds > 0) made.push({ key: 'stream_hours', label: 'Hours streamed', count: Math.round((seconds / 3600) * 10) / 10, unit: 'hours' });++ }++ if (Array.isArray(reports)) {++ const fromAt = at - (windowDays - 1) * DAY;++ const count = reports.filter((r) => { const t = reportAt(r); return t != null && t >= fromAt; }).length;++ if (count > 0) made.push({ key: 'agent_reports', label: 'Agent reports', count, unit: 'reports', href: `${base}/you#rp-you-sr` });++ }++ if (made.length) sections.made = { items: made };++++ const aiTotals = ai && ai.totals && typeof ai.totals === 'object' ? ai.totals : null;++ if (aiTotals) {++ const calls = int(aiTotals.calls);++ if (calls > 0) {++ const tokens = (int(aiTotals.input_tokens) || 0) + (int(aiTotals.output_tokens) || 0);++ const daily = (Array.isArray(ai.rows) ? ai.rows : []).map((r) => ({ day: String(r && r.key || '').slice(0, 10), calls: int(r && r.calls) || 0 }))++ .filter((r) => /^\d{4}-\d{2}-\d{2}$/.test(r.day)).slice(-90);++ sections.ai = { calls, tokens, cost_usd: Math.max(0, n(aiTotals.cost_usd) || 0), ...(daily.length ? { daily } : {}) };++ }++ }++++ const revenue = stats && stats.revenue && typeof stats.revenue === 'object' ? stats.revenue : null;++ if (revenue) {++ const transactions = int(revenue.transactions);++ const settled = int(revenue.settled_bits);++ if (transactions > 0 || settled > 0) {++ sections.money = { currency: 'vibes-bits', settled_bits: settled || 0, transactions: transactions || 0 };++ }++ }++++ const document = {++ generated_at: iso(at),++ person,++ window: { days: windowDays, from, to: dayOf(at) },++ published,++ sections: sectionsFor(sections, published, true),++ ...(notes.length ? { note: notes.join(' ') } : {}),++ };++ await save({ subject, person, published, document }).catch((err) => {++ try { (log.warn || log.error || console.warn).call(log, `[OpenVibe.Report] me-stats snapshot was not stored: ${(err && err.message) || 'database error'}`); } catch { /* */ }++ });++ return document;++ }++++ async function setPublished({ user, token = null, days = 30, published } = {}) {++ if (!db) throw new Error('me-stats: no database');++ const person = personOf(user);++ const subject = user && typeof user.subject_id === 'string' ? user.subject_id.trim() : '';++ if (!person || !subject) throw new Error('me-stats: a signed-in person is required');++ const switches = publishedOf(published);++ await db.prepare(`INSERT INTO report_me_stats (subject, username, display_name, avatar_url, published, updated_at)++ VALUES (?, ?, ?, ?, ?::jsonb, now())++ ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,++ avatar_url = excluded.avatar_url, published = excluded.published, updated_at = now()`)++ .run(subject, person.username, person.display_name, person.avatar_url || null, JSON.stringify(switches));++ // Recompute at the moment of the switch, so a section made public is current, not the last visit's.++ return compute({ user, token, days });++ }++++ /** The public document: the snapshot with only the published sections. null when there is no such person/row. */++ async function publicFor(username) {++ if (!db || !USERNAME_RE.test(String(username || ''))) return null;++ let row;++ try { row = await db.prepare('SELECT * FROM report_me_stats WHERE lower(username) = lower(?)').get(String(username)); } catch { return null; }++ if (!row) return null;++ const published = publishedOf(jsonOf(row.published));++ const snapshot = jsonOf(row.snapshot);++ const person = snapshot && snapshot.person && typeof snapshot.person === 'object'++ ? snapshot.person++ : { username: row.username, display_name: row.display_name || row.username, ...(row.avatar_url ? { avatar_url: row.avatar_url } : {}) };++ const base = snapshot && typeof snapshot === 'object' ? snapshot : {};++ const sections = sectionsFor(base.sections || {}, published, false);++ return {++ generated_at: typeof base.generated_at === 'string' ? base.generated_at : iso(timeOf(row.snapshot_at) || timeOf(row.updated_at) || clock()),++ person,++ window: base.window && typeof base.window === 'object' ? base.window : { days: 30, from: dayOf(clock() - 29 * DAY), to: dayOf(clock()) },++ published,++ sections,++ };++ }++++ /** The people a sitemap can list: only those who published at least one section. */++ async function listPublished({ limit = 500 } = {}) {++ if (!db) return [];++ const cap = Math.max(1, Math.min(2000, Number(limit) || 500));++ let rows;++ try { rows = await db.prepare('SELECT username, published, snapshot, snapshot_at, updated_at FROM report_me_stats ORDER BY updated_at DESC LIMIT ?').all(cap); } catch { return []; }++ return rows.filter((r) => anyPublished(publishedOf(jsonOf(r.published)))).map((r) => {++ const snapshot = jsonOf(r.snapshot);++ return {++ username: r.username,++ generated_at: snapshot && typeof snapshot.generated_at === 'string' ? snapshot.generated_at : iso(timeOf(r.snapshot_at) || timeOf(r.updated_at) || clock()),++ updated_at: iso(timeOf(r.updated_at) || clock()),++ };++ });++ }++++ return { compute, setPublished, publicFor, listPublished, SECTIONS, publishedOf, anyPublished };++}++++/**++ * The sections a document may carry: all of them for the owner, only the published ones for a public reader. A++ * published section whose source did not answer stays absent (there is nothing in `sections` to copy).++ */++function sectionsFor(sections, published, owner) {++ const out = {};++ for (const key of SECTIONS) {++ if (!owner && published[key] !== true) continue;++ const value = sections && sections[key];++ if (value && typeof value === 'object') out[key] = value;++ }++ return out;++}++++module.exports = { createMeStats, fromHistory, publishedOf, personOf, sectionsFor, SECTIONS, HEAT_WEEKS, TOP_SITES };@@@ -101,8 +101,17 @@ const NAV = [{ label: 'API', href: '/developers', icon: 'fa-code', more: true },];--function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [], agent = false, inlineStyles = [] }) {++function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [], agent = false, inlineStyles = [], og = null }) {canonical = canonical || (path ? canon(path) : undefined);++ const ogTags = og ? [++ `<meta property="og:type" content="${esc(og.type || 'website')}">`,++ `<meta property="og:site_name" content="${esc(NAME)}">`,++ `<meta property="og:title" content="${esc(og.title || title || NAME)}">`,++ `<meta property="og:description" content="${esc(og.description || description || '')}">`,++ og.url ? `<meta property="og:url" content="${esc(og.url)}">` : '',++ og.image ? `<meta property="og:image" content="${esc(og.image)}">` : '',++ '<meta name="twitter:card" content="summary">',++ ].filter(Boolean).join('\n') : '';const navbar = {service: 'report',apiBase: NETWORK,@@@ -143,6 +152,7 @@ ${path && String(path).startsWith('/ai') ? `<link rel="alternate" type="applicat<script src="${esc(ovServe.url('kit-data-web.js'))}" defer></script><script src="${esc(ovServe.url('motion.js'))}" defer></script>${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}++${ogTags}<meta name="ov-boost" content="report@${esc(RELEASE)}">`,body: `<a class="skip" href="#main">Skip to content</a><div id="navbar-mount"></div>@@@ -620,7 +630,7 @@ ${(sp.topics || []).length ? `<ul class="rp-chips">${sp.topics.slice(0, 12).map(${(sp.episodes || []).length ? `<p class="rp-meta">${plural(sp.episodes.length, 'stream', 'streams')} written up · <a href="/rundown/s/${encodeURIComponent(username)}">all of it</a></p>` : ''}</section>` : '';const body = `<header class="rp-page-h rp-person">${kit.mark({ name, src: user.avatar_url || '', size: 64 })}<div><p class="rp-kicker">Your numbers</p><h1>${esc(name)}</h1>--<p class="rp-lede rp-you-say">${esc(youSentence({ name, days, stats, speaker }))}</p><nav class="rp-tabs" aria-label="Time range">${tabs}</nav></div></header>++<p class="rp-lede rp-you-say">${esc(youSentence({ name, days, stats, speaker }))}</p><nav class="rp-tabs" aria-label="Time range">${tabs}</nav><p class="rp-meta"><a href="/me">All your stats: your places, activity and what you made</a></p></div></header>${streams}${money}${youAi(ai, days)}${youReports(reports, defaultAudience)}${rundown}<section aria-labelledby="rp-you-next" class="rp-you-next"><h2 id="rp-you-next">Coming here</h2><p class="rp-fine">Your agents' work and what your computers ran. Only you see this page.</p></section>`;return layout({ title: 'Your numbers', description: 'Your own numbers across OpenVibe: streams, money and The Rundown, in one place.', body, active: '/you', robots: 'noindex, nofollow' });@@@ -1205,8 +1215,206 @@ function staffOnly({ signedIn = false } = {}) {return layout({ title: 'Staff only', description: 'Staff only.', robots: 'noindex, nofollow', body: `<header class="rp-page-h"><p class="rp-kicker">OpenVibe.Report</p><h1>Staff only</h1><p class="rp-lede">${esc(text)}</p>${action}</header>` });}++// ── A person's stats (the owner, 2026-11-09): private /me, public /u/<name> ─────────────────────────────++//++// Every number is a source's: Network's cross-site history (visits, not minutes), Report's analytics (streams and++// hours), Report's AI meter, Billing's settled Vibes and the person's session reports. A section a source did not++// answer for is absent, never a zero. The charts are server-drawn SVG with the numbers in their aria-label, and the++// tables are the same numbers as text.++const STAT_SECTIONS = [++ ['sites', 'Sites you used', 'The OpenVibe sites you opened, from Network’s history. A page opened again within ten minutes is one visit.'],++ ['activity', 'Your activity', 'The days you were around, week by week, and the streak that ends today.'],++ ['made', 'Things you made', 'What Report can count so far: your streams and hours, and the agent sessions you ran.'],++ ['ai', 'Your AI', 'Your own calls on the AI meter: what they cost and how many there were.'],++ ['money', 'Vibes', 'What Billing says reached you. Report stores no money and derives no amount.'],++];++const STAT_LABEL = Object.fromEntries(STAT_SECTIONS.map(([k, label]) => [k, label]));++// The public page speaks about the person, never to the reader: the same sections, in the third person.++const STAT_PUBLIC_NOTE = {++ sites: 'The OpenVibe sites they opened, from Network’s history. A page opened again within ten minutes is one visit.',++ activity: 'The days they were around, week by week, and the streak that ends today.',++ made: 'What Report can count so far: their streams and hours, and the agent sessions they ran.',++ ai: 'Their own calls on the AI meter: what they cost and how many there were.',++ money: 'What Billing says reached them. Report stores no money and derives no amount.',++};++++function statSites(sites) {++ if (!sites || !Array.isArray(sites.month) || !sites.month.length) {++ return kit.empty({ icon: 'compass', title: 'No places yet', text: 'When you use OpenVibe signed in, the sites you open show up here. Nothing is recorded for signed-out visits.' });++ }++ const week = new Map((Array.isArray(sites.week) ? sites.week : []).map((s) => [s.service, s.visits]));++ const top = Math.max(1, ...sites.month.map((s) => Number(s.visits) || 0));++ // Designed 11-09: a bar per site reads at a glance (it was a table of two numbers per site).++ const rows = sites.month.map((s, i) => {++ const n = Number(s.visits) || 0, w = week.get(s.service) || 0;++ return `<li style="--p:${Math.max(3, Math.round(n / top * 100))}%;--i:${i}"><a href="${esc(s.url)}">${esc(s.label)}</a>`++ + `<span class="rp-sbar" aria-hidden="true"><i></i></span><b>${fmt(n)}</b><small>${w ? `${fmt(w)} this week` : 'none this week'}</small></li>`;++ }).join('');++ return `<ol class="rp-sites" aria-label="Visits by site in this window, with this week's">${rows}</ol>++<p class="rp-fine">Counted from Network’s cross-site history: a visit is a page, not a length of time. Times are UTC.</p>`;++}++++/** Twelve weeks as a grid of days (a column a week, Monday on top), shaded by visits; the numbers stay in a table. */++function heatmap(weeks) {++ const all = weeks.flatMap((w) => (w.days || []).map((n) => Number(n) || 0));++ const max = Math.max(1, ...all);++ const level = (n) => (n <= 0 ? 0 : Math.min(4, Math.ceil(n / max * 4)));++ const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];++ const cols = weeks.map((w) => `<span class="rp-heat-col">${DAYS.map((d, j) => {++ const n = Number((w.days || [])[j]) || 0;++ return `<i class="l${level(n)}" title="${esc(`${d}, week of ${day(w.week_start)}: ${n} ${n === 1 ? 'visit' : 'visits'}`)}"></i>`;++ }).join('')}</span>`).join('');++ return `<div class="rp-heat" role="img" aria-label="${esc(`Visits a day for ${weeks.length} weeks; the table below has every number`)}">++<span class="rp-heat-days" aria-hidden="true"><i>Mon</i><i></i><i>Wed</i><i></i><i>Fri</i><i></i><i>Sun</i></span><span class="rp-heat-grid">${cols}</span></div>++<p class="rp-heat-key" aria-hidden="true"><span>${esc(day(weeks[0].week_start))}</span><span class="rp-heat-scale">Fewer <i class="l0"></i><i class="l1"></i><i class="l2"></i><i class="l3"></i><i class="l4"></i> More</span><span>${esc(day(weeks[weeks.length - 1].week_start))}</span></p>`;++}++++function statActivity(activity) {++ if (!activity || !Array.isArray(activity.weeks) || !activity.weeks.length) return '';++ if (!activity.active_days && !activity.streak_days) {++ return kit.empty({ icon: 'calendar', title: 'No activity yet', text: 'Your days fill in as you use OpenVibe signed in. Nothing is recorded for signed-out visits.' });++ }++ const weeks = activity.weeks.map((w) => ({ week_start: w.week_start, week: day(w.week_start), total: (w.days || []).reduce((a, b) => a + (Number(b) || 0), 0), days: w.days || [] }));++ const chart = bars(weeks.map((w) => ({ day: w.week_start, n: w.total })), 'Visits a week', { h: 90 });++ const rows = activity.weeks.map((w, i) => ({++ week: day(w.week_start),++ mon: (w.days && w.days[0]) || 0, tue: (w.days && w.days[1]) || 0, wed: (w.days && w.days[2]) || 0, thu: (w.days && w.days[3]) || 0,++ fri: (w.days && w.days[4]) || 0, sat: (w.days && w.days[5]) || 0, sun: (w.days && w.days[6]) || 0,++ }));++ return `<div class="rp-tiles rp-tiles-sm">${tile('Active days', exact(activity.active_days))}${tile('Streak', exact(activity.streak_days), Number(activity.streak_days) === 1 ? 'day in a row' : 'days in a row')}</div>++${heatmap(activity.weeks)}++${chart}++<details class="rp-fold"><summary>Every day as numbers <span>${activity.weeks.length} weeks</span></summary>++${kit.table({++ caption: 'Visits by day, last twelve weeks (UTC)',++ stack: false,++ columns: [++ { key: 'week', label: 'Week of', primary: true },++ { key: 'mon', label: 'Mon', num: true }, { key: 'tue', label: 'Tue', num: true }, { key: 'wed', label: 'Wed', num: true },++ { key: 'thu', label: 'Thu', num: true }, { key: 'fri', label: 'Fri', num: true }, { key: 'sat', label: 'Sat', num: true }, { key: 'sun', label: 'Sun', num: true },++ ],++ rows,++ })}</details>`;++}++++function statMade(made, { publicView = false } = {}) {++ const items = made && Array.isArray(made.items) ? made.items : [];++ if (!items.length) return kit.empty({ icon: 'sparkles', title: 'Nothing counted yet', text: 'Streams, hours and your agent reports appear here when Report has them for this window.' });++ return `<div class="rp-tiles rp-tiles-sm">${items.map((it) => tile(it.label, `${fmt(it.count)}<small> ${esc(it.unit || '')}</small>`, (it.href && (!publicView || it.key === 'streams') ? `<a href="${esc(it.href)}">See them</a>` : ''))).join('')}</div>`;++}++++function statAi(ai, { publicView = false } = {}) {++ if (!ai || !(Number(ai.calls) > 0)) return '';++ const daily = (ai.daily || []).map((d) => ({ day: d.day, n: d.calls }));++ return `<div class="rp-tiles rp-tiles-sm">${tile('Calls', exact(ai.calls))}${tile('Tokens', fmt(ai.tokens))}${tile('Cost', usd(ai.cost_usd))}</div>++${daily.length > 1 ? bars(daily, 'AI calls a day', { h: 100 }) : ''}++${publicView ? '' : '<p class="rp-meta"><a href="/you#rp-you-ai">The whole AI meter</a> · <a href="/api/v1/me/ai/usage">As JSON</a></p>'}`;++}++++function statMoney(money) {++ if (!money || !(Number(money.transactions) > 0)) return '';++ return `<div class="rp-tiles rp-tiles-sm">${tile('Vibes earned', exact(money.settled_bits))}${tile('Payments', exact(money.transactions))}</div>++<p class="rp-fine">These are Billing’s settled amounts. Report never computes a price, a fee or a balance: <a href="https://billing.openvibe.network/">every receipt is on Billing</a>.</p>`;++}++++/** One publish row: a checkbox and its hidden false, so an unchecked box is an explicit "no". */++function publishRow(key, checked, note) {++ return `<li class="rp-publish-row"><label><input type="hidden" name="${esc(key)}" value="false"><input class="rp-switch" type="checkbox" role="switch" name="${esc(key)}" value="true"${checked ? ' checked' : ''}> <b>${esc(STAT_LABEL[key] || key)}</b></label> <span class="rp-fine">· ${esc(note)}</span></li>`;++}++++function publishPanel({ doc, username }) {++ const p = doc.published || {};++ const any = Object.values(p).some(Boolean);++ const publicUrl = `${BASE}/u/${encodeURIComponent(username)}`;++ return `<section class="rp-share" id="rp-share" aria-labelledby="rp-pub-h"><h2 id="rp-pub-h">Share your stats</h2>++<p class="rp-lede">Private by default: only you see this page. Turn a section on to publish it at <a href="/u/${encodeURIComponent(username)}">/u/${esc(username)}</a>. Each section is a separate choice; a published section shows the numbers in this snapshot.</p>++<form class="rp-publish" method="post" action="/api/v1/me/stats/published">++<fieldset><legend class="rp-sr">Public sections</legend><ul>${STAT_SECTIONS.map(([key]) => publishRow(key, p[key], p[key] ? 'Public now' : 'Only you')).join('')}</ul>++${kit.button({ label: 'Save public sections', icon: 'check' })}++</fieldset></form>++${any ? `<div class="rp-share-card"><p class="rp-kicker">Share card</p><h3>${esc(doc.person.display_name || username)}’s OpenVibe stats</h3><p class="rp-fine">Anyone with this link sees only the sections you made public.</p><p><a href="/u/${encodeURIComponent(username)}">${esc(publicUrl)}</a></p></div>` : `<p class="rp-fine">Nothing is public yet. Your public link will be <code>/u/${esc(username)}</code>.</p>`}++</section>`;++}++++function me({ user, doc, days = 30 } = {}) {++ const name = String((user && (user.display_name || user.username)) || doc.person.display_name || 'you');++ const username = String(doc.person.username || (user && user.username) || '');++ const s = doc.sections || {};++ const tabs = [7, 30, 90].map((d) => `<a href="/me?days=${d}"${Number(days) === d ? ' aria-current="page"' : ''}>${d} days</a>`).join('');++ const body = `<header class="rp-page-h">${kit.mark({ name, src: (user && user.avatar_url) || doc.person.avatar_url || '', size: 64 })}<div><p class="rp-kicker">Your stats</p><h1>${esc(name)}</h1>++<p class="rp-lede">Everything OpenVibe counts for you, in one place. Private by default; you choose what is public.</p><nav class="rp-tabs" aria-label="Time range">${tabs}</nav></div></header>++${doc.note ? kit.notice({ kind: 'info', text: doc.note }) : ''}++${(() => { const n = Object.values(doc.published || {}).filter(Boolean).length; return `<p class="rp-privacy${n ? ' is-public' : ''}"><span>${n ? `${n} ${n === 1 ? 'section is' : 'sections are'} public` : 'Private: only you see this page'}</span> <a href="#rp-share">${n ? 'Change' : 'Share some of it'}</a></p>`; })()}++${s.sites ? kit.section({ title: 'Sites you used', count: (s.sites.month || []).length, note: STAT_SECTIONS[0][2], body: statSites(s.sites), id: 'rp-me-sites' }) : ''}++${s.activity ? kit.section({ title: 'Your activity', note: STAT_SECTIONS[1][2], body: statActivity(s.activity), id: 'rp-me-activity' }) : ''}++${s.made ? kit.section({ title: 'Things you made', note: STAT_SECTIONS[2][2], body: statMade(s.made), id: 'rp-me-made' }) : ''}++${s.ai ? kit.section({ title: 'Your AI', note: STAT_SECTIONS[3][2], body: statAi(s.ai), id: 'rp-me-ai' }) : ''}++${s.money ? kit.section({ title: 'Vibes', note: STAT_SECTIONS[4][2], body: statMoney(s.money), id: 'rp-me-money' }) : ''}++${publishPanel({ doc, username })}++<p class="rp-fine">Counted ${esc(dayTime(doc.generated_at))}. <a href="/you">Your streams, money, AI and The Rundown</a> · <a href="/api/v1/me/stats?days=${esc(days)}">Your numbers as JSON</a>.</p>`;++ return layout({ title: 'Your stats', description: 'Your own numbers across OpenVibe: the sites you used, your activity, what you made, your AI and your Vibes.', body, active: '/me', robots: 'noindex, nofollow' });++}++++function meSignedOut() {++ const body = `<header class="rp-page-h"><p class="rp-kicker">Your stats</p><h1>Your OpenVibe, in numbers</h1>++<p class="rp-lede">The sites you use, the days you were around, what you made, your AI and the Vibes that reached you. Private by default: you choose which sections, if any, are public.</p>++<p>${kit.button({ href: `/auth/login?next=${encodeURIComponent('/me')}`, label: 'Sign in to see yours', icon: 'forward' })}</p>++<p class="rp-fine">A public stats page looks like <a href="/stats">the rest of Report</a>: server-rendered numbers, no account needed to read what someone chose to publish.</p></header>`;++ return layout({ title: 'Your stats', description: 'Sign in to see your own numbers across OpenVibe.', body, active: '/me', robots: 'noindex, nofollow' });++}++++function meError({ user } = {}) {++ const name = String((user && (user.display_name || user.username)) || 'there');++ return layout({ title: 'Your stats', robots: 'noindex, nofollow', body: `<header class="rp-page-h"><p class="rp-kicker">Your stats</p><h1>${esc(name)}, your numbers did not load</h1><p class="rp-lede">One of the sources did not answer. Nothing is shown as a zero. Try again in a moment; <a href="/you">your streams, AI and Rundown</a> are still here.</p></header>` });++}++++function publicStats({ doc } = {}) {++ const person = doc.person || {};++ const username = String(person.username || '');++ const name = String(person.display_name || username);++ const s = doc.sections || {};++ const any = Object.values(doc.published || {}).some(Boolean);++ const body = `<header class="rp-page-h">${kit.mark({ name, src: person.avatar_url || '', size: 64 })}<div><p class="rp-kicker">Public stats</p><h1>${esc(name)}’s OpenVibe</h1>++<p class="rp-lede">The numbers ${esc(name)} chose to publish. Private by default: nothing else is here.</p></div></header>++${s.sites ? kit.section({ title: 'Sites used', count: (s.sites.month || []).length, note: STAT_PUBLIC_NOTE.sites, body: statSites(s.sites), id: 'rp-u-sites' }) : ''}++${s.activity ? kit.section({ title: 'Activity', note: STAT_PUBLIC_NOTE.activity, body: statActivity(s.activity), id: 'rp-u-activity' }) : ''}++${s.made ? kit.section({ title: 'Things made', note: STAT_PUBLIC_NOTE.made, body: statMade(s.made, { publicView: true }), id: 'rp-u-made' }) : ''}++${s.ai ? kit.section({ title: 'AI', note: STAT_PUBLIC_NOTE.ai, body: statAi(s.ai, { publicView: true }), id: 'rp-u-ai' }) : ''}++${s.money ? kit.section({ title: 'Vibes', note: STAT_PUBLIC_NOTE.money, body: statMoney(s.money), id: 'rp-u-money' }) : ''}++<p class="rp-fine">Published ${esc(dayTime(doc.generated_at))} · ${any ? 'only the sections above' : 'no sections'} · <a href="/stats">the network’s open numbers</a>.</p>`;++ return layout({++ title: `${name}: OpenVibe stats`,++ description: `${name}’s OpenVibe numbers: the sites they used, their activity and what they made. Only the sections they made public.`,++ path: `/u/${encodeURIComponent(username)}`,++ robots: 'index, follow',++ jsonLd: [{++ '@context': 'https://schema.org',++ '@type': 'Person',++ name,++ alternateName: username,++ url: canon(`/u/${encodeURIComponent(username)}`),++ ...(person.avatar_url ? { image: person.avatar_url } : {}),++ description: `${name}’s public OpenVibe stats, published by them on OpenVibe.Report.`,++ }],++ og: { type: 'profile', title: `${name}: OpenVibe stats`, description: `${name}’s public OpenVibe numbers. Private by default.`, url: canon(`/u/${encodeURIComponent(username)}`), image: person.avatar_url || undefined },++ body,++ active: '/me',++ });++}++++function publicStatsPrivate({ doc } = {}) {++ const person = (doc && doc.person) || {};++ const name = String(person.display_name || person.username || 'This person');++ return layout({++ title: `${name}: private stats`,++ robots: 'noindex, nofollow',++ body: `<header class="rp-page-h"><p class="rp-kicker">Private</p><h1>${esc(name)} keeps their numbers private</h1><p class="rp-lede">Nothing has been published from this page. On OpenVibe.Report, a person’s stats are private until they turn a section on.</p><p class="rp-fine"><a href="/stats">The network’s open numbers</a> · <a href="/">OpenVibe.Report</a></p></header>`,++ active: '/me',++ });++}++function notFound() {return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });}--module.exports = { home, stats, rundown, sessionReport, sharingChip, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiBenchmark, aiModel, aiCompare, aiMethod, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };++module.exports = { home, stats, rundown, sessionReport, sharingChip, topic, episode, speaker, recap, channel, you, youSignedOut, me, meSignedOut, meError, publicStats, publicStatsPrivate, developers, aiIndex, aiBoard, aiBenchmark, aiModel, aiCompare, aiMethod, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };@@@ -90,6 +90,19 @@ function defaults() {channel: {recaps: [{ stream_id: 7, title: 'Seattle Today', headline: 'A tiny but mighty vibe', grade: 'A', duration_seconds: 2017, ended_at: '2026-10-27 10:33:00' }],},++ // The person's own cross-site history (GET /api/history), used by /me and GET /api/v1/me/stats. A test that++ // wants "Network did not answer" overrides it with null.++ history: {++ items: [++ { service: 'live', service_label: 'Live', title: 'A stream', url: 'https://openvibe.live/watch/7', updated_at: '2026-11-09 10:00:00', hits: 1 },++ { service: 'live', service_label: 'Live', title: 'Another stream', url: 'https://openvibe.live/watch/8', updated_at: '2026-11-08 10:00:00', hits: 1 },++ { service: 'community', service_label: 'Community', title: 'A paste', url: 'https://openvibe.community/paste/1', updated_at: '2026-11-07 10:00:00', hits: 1 },++ { service: 'chat', service_label: 'Chat', title: 'A room', url: 'https://openvibe.chat/room', updated_at: '2026-10-20 10:00:00', hits: 1 },++ ],++ total: 4,++ paused: false,++ services: [{ service: 'live', label: 'Live', count: 2 }, { service: 'community', label: 'Community', count: 1 }, { service: 'chat', label: 'Chat', count: 1 }],++ },};}@@@ -118,6 +131,9 @@ function stubFetch(data = {}, { health = { network: true, live: true } } = {}) {if (p.endsWith('/api/v1/stats')) {return answers.stats == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.stats);}++ if (p.endsWith('/api/history')) {++ return answers.history == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.history);++ }if (p.endsWith('/api/rundown/')) {return answers.rundown == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.rundown);}@@@ -178,6 +194,7 @@ async function boot(opts = {}) {sso: opts.sso,shadow: opts.shadow,...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),++ ...(opts.sessionReports ? { sessionReports: opts.sessionReports } : {}),...(opts.bench ? { bench: opts.bench } : {}),...(opts.aiCatalogue ? { aiCatalogue: opts.aiCatalogue } : {}),...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),nnew file mode 100644@@@ -0,0 +1,238 @@++'use strict';++/**++ * A person's stats (the owner, 2026-11-09): /me, GET /api/v1/me/stats, the per-section publish switches, the public++ * /u/<name> page and GET /api/v1/u/:name/stats. The sources are stubbed: Network's cross-site history, Report's++ * analytics, the AI meter and session reports; the store is a real migrated PGlite, so the snapshot and the switches++ * are tested where they live.++ *++ * The rules under test: the subject is the session's and never a query value; a source that does not answer leaves its++ * section out (never a zero); a public reader sees only the published sections; a private person's API is a 404 and++ * their page is noindex; the public page carries real words, a heading, a canonical and Person JSON-LD.++ */++const assert = require('assert');++const crypto = require('crypto');++const { createSsoClient } = require('openvibe-sdk/sso');++const { boot, check, done } = require('./helpers/boot');++++let contracts = null;++try {++ contracts = require('openvibe-contracts');++ contracts.resolve('report.me-stats@1');++} catch { contracts = null; }++++const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++function token(claims) {++ const head = b64({ alg: 'RS256', typ: 'JWT', kid: 'test' });++ const now = Math.floor(Date.now() / 1000);++ const body = b64({ iss: 'https://openvibe.network', aud: ['openvibe.network'], iat: now, exp: now + 600, ...claims });++ const sig = crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), privateKey).toString('base64url');++ return `${head}.${body}.${sig}`;++}++const goosely = token({ sub: '7', subject_id: 'usr_01JTESTGOOSELY0000000000000', username: 'goosely', display_name: 'Goosely' });++const quiet = token({ sub: '8', subject_id: 'usr_01JTESTQUIET00000000000000', username: 'quiet', display_name: 'Quiet' });++++const NOW = Date.UTC(2026, 10, 9, 12, 0, 0); // 2026-11-09, a Monday++const viewed = [];++const analytics = {++ source: 'network',++ async channel(name) {++ if (name === 'quiet') return null;++ return {++ channel: name, days: 30,++ totals: { streams: 3, stream_seconds: 9000, peak_viewers: 41, avg_viewers: 12, unique_chatters: 9, messages: 230, watch_minutes: 2100 },++ daily: [],++ revenue: { source: 'billing', currency: 'vibes-bits', days: 30, transactions: 2, settled_bits: 1200, refunded_bits: 0 },++ };++ },++ async stats() { return null; },++ metrics: async () => null,++ reportStats: () => null,++};++const aiUsage = {++ async forPerson({ subject, from, group }) {++ viewed.push({ subject, from, group });++ if (subject.includes('QUIET')) return null;++ return {++ group, source: 'raw',++ rows: [{ key: '2026-11-08', calls: 12, cost_usd: 0.2 }, { key: '2026-11-09', calls: 9, cost_usd: 0.22 }],++ totals: { calls: 21, input_tokens: 30000, output_tokens: 18000, cached_tokens: 9000, cost_usd: 0.42, fee_usd: 0.02, charged_usd: 0.44, saved_usd: 0.01 },++ by_paid_by: { openvibe_key: { calls: 21, charged_usd: 0.44, fee_usd: 0.02 } },++ insights: ['Your calls cost $0.44, including $0.02 in fees.'],++ };++ },++};++const sessionReports = {++ async listForOwner(owner) { return [{ id: 'rps_01JTESTREPORT0000000000000', owner, created_at: '2026-11-08T10:00:00Z', task: { title: 'A session' } }]; },++ async listPublic() { return []; },++ async defaultAudience() { return 'private'; },++};++++(async () => {++ const sso = createSsoClient({ site: 'report', baseUrl: 'https://openvibe.report', clientId: 'report', clientSecret: 'x', networkUrl: 'https://openvibe.network', publicKey: publicKey.export({ type: 'spki', format: 'pem' }), log: { warn() {}, info() {}, log() {}, error() {} } });++ const t = await boot({++ db: true,++ now: () => NOW,++ sso,++ analytics,++ aiUsage,++ sessionReports,++ env: { OV_NETWORK_PUBLIC_KEY: publicKey.export({ type: 'spki', format: 'pem' }) },++ });++ const auth = { cookie: `ov_token=${goosely}` };++ const publicApi = '/api/v1/u/goosely/stats';++ try {++ await check('signed out: the API is 401 and /me asks for a sign-in', async () => {++ const api = await t.get('/api/v1/me/stats');++ assert.strictEqual(api.status, 401);++ assert.strictEqual(api.json().code, 'auth.sign_in_required');++ const page = await t.get('/me');++ assert.strictEqual(page.status, 200);++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');++ assert.ok(page.text.includes('Sign in to see yours'), 'the sign-in call to action');++ assert.match(page.text, /<meta name="robots" content="noindex, nofollow">/);++ });++++ await check('signed in: one document with every section the sources answered for', async () => {++ const r = await t.get('/api/v1/me/stats?days=30', { headers: auth });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ const body = r.json();++ assert.strictEqual(body.person.username, 'goosely');++ assert.deepStrictEqual(body.window, { days: 30, from: '2026-10-11', to: '2026-11-09' });++ assert.deepStrictEqual(body.published, { sites: false, activity: false, made: false, ai: false, money: false });++ assert.strictEqual(body.sections.sites.month[0].service, 'live');++ assert.strictEqual(body.sections.sites.month[0].visits, 2);++ assert.strictEqual(body.sections.sites.week[0].visits, 2);++ assert.ok(body.sections.sites.top.length <= 8);++ assert.strictEqual(body.sections.activity.weeks.length, 12);++ assert.strictEqual(body.sections.activity.weeks[11].days.length, 7);++ assert.strictEqual(body.sections.activity.streak_days, 3, 'the 9th, 8th and 7th have history');++ assert.strictEqual(body.sections.ai.calls, 21);++ assert.strictEqual(body.sections.ai.tokens, 48000);++ assert.strictEqual(body.sections.money.settled_bits, 1200);++ assert.strictEqual(body.sections.money.transactions, 2);++ assert.ok(body.sections.made.items.some((i) => i.key === 'streams' && i.count === 3));++ assert.ok(body.sections.made.items.some((i) => i.key === 'agent_reports' && i.count === 1));++ assert.strictEqual(viewed[viewed.length - 1].subject, 'usr_01JTESTGOOSELY0000000000000', 'the session subject, not a query');++ if (contracts) {++ const v = contracts.validate('report.me-stats@1', body);++ assert.ok(v.valid, `report.me-stats@1: ${JSON.stringify(v.errors)}`);++ }++ });++++ await check('/me: real words, a heading, each section, the switches and the numbers as text', async () => {++ const r = await t.get('/me?days=30', { headers: auth });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);++ for (const words of ['Share your stats', 'Private: only you see this page', 'Sites you used', 'Your activity', 'Things you made', 'Your AI', 'Vibes', 'this week', 'Visits by day, last twelve weeks', 'Every day as numbers', 'Live', 'Community', 'Chat']) {++ assert.ok(r.text.includes(words), `the page says ${words}`);++ }++ assert.ok(r.text.includes('Only you'), 'each switch says its private state');++ assert.ok(r.text.includes('action="/api/v1/me/stats/published"'), 'the publish form');++ assert.ok(r.text.includes('aria-label="Visits a week'), 'the chart carries its numbers for screen readers');++ assert.ok(r.text.includes('href="/api/v1/me/stats?days=30"'), 'the JSON link');++ });++++ await check('a source that does not answer leaves its section out, never a zero', async () => {++ const direct = await t.ctx.meStats.compute({ user: { subject_id: 'usr_01JTESTQUIET00000000000000', username: 'quiet', display_name: 'Quiet' }, token: null, days: 7 });++ assert.ok(!direct.sections.sites && !direct.sections.activity, 'no history: no sites and no activity');++ assert.ok(!direct.sections.ai, 'no AI meter: no AI section');++ assert.ok(direct.note && /Network|analytics/.test(direct.note), 'the note says what did not answer');++ });++++ await check('the switches: a JSON PUT sets the five public choices', async () => {++ const r = await t.get('/api/v1/me/stats?days=30', { headers: auth });++ assert.strictEqual(r.status, 200);++ const all = r.json();++ assert.ok(all.sections.ai, 'the owner sees AI');++ const put = await t.get('/api/v1/me/stats/published', {++ method: 'PUT',++ headers: { ...auth, 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },++ json: { published: { sites: true, activity: true, made: false, ai: false, money: false } },++ });++ assert.strictEqual(put.status, 200, put.text);++ const body = put.json();++ assert.deepStrictEqual(body.published, { sites: true, activity: true, made: false, ai: false, money: false });++ assert.ok(body.sections.sites && body.sections.activity);++ assert.ok(body.sections.ai && body.sections.money && body.sections.made, 'the owner still sees the sections they kept private');++ });++++ await check('the public API and page carry only the published sections', async () => {++ const api = await t.get(publicApi);++ assert.strictEqual(api.status, 200);++ const body = api.json();++ assert.deepStrictEqual(body.published, { sites: true, activity: true, made: false, ai: false, money: false });++ assert.ok(body.sections.sites && body.sections.activity);++ assert.ok(!body.sections.ai && !body.sections.money && !body.sections.made, 'a stranger never sees an unpublished section');++ if (contracts) assert.ok(contracts.validate('report.me-stats@1', body).valid);++ const page = await t.get('/u/goosely');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<meta name="robots" content="index, follow">/);++ assert.ok(page.text.includes('Goosely’s OpenVibe') || page.text.includes('Goosely\'s OpenVibe'));++ assert.ok(page.text.includes('"@type":"Person"'), 'Person JSON-LD');++ assert.ok(page.text.includes('canonical" href="https://openvibe.report/u/goosely"'));++ assert.ok(page.text.includes('Sites used') && page.text.includes('Activity'));++ assert.ok(!page.text.includes('Your AI') && !page.text.includes('Things made'), 'unpublished sections are absent');++ assert.ok(page.text.includes('Published'), 'the snapshot is dated');++ });++++ await check('a private person: the API is a 404 and the page is a noindex notice', async () => {++ const put = await t.get('/api/v1/me/stats/published', {++ method: 'PUT',++ headers: { ...auth, 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },++ json: { published: { sites: false, activity: false, made: false, ai: false, money: false } },++ });++ assert.strictEqual(put.status, 200);++ const api = await t.get(publicApi);++ assert.strictEqual(api.status, 404);++ assert.strictEqual(api.json().code, 'report.person_stats_not_found');++ const page = await t.get('/u/goosely');++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<meta name="robots" content="noindex, nofollow">/);++ assert.ok(page.text.includes('keeps their numbers private'));++ assert.ok(!page.text.includes('Visits, 30 days'), 'no numbers on a private page');++ const unknown = await t.get('/u/nobody');++ assert.strictEqual(unknown.status, 404);++ });++++ await check('the page form saves all five switches, then /me shows them', async () => {++ const body = 'sites=true&activity=true&made=true&ai=true&money=true';++ const post = await t.get('/api/v1/me/stats/published', {++ method: 'POST',++ headers: { ...auth, 'content-type': 'application/x-www-form-urlencoded', 'sec-fetch-site': 'same-origin' },++ body,++ });++ assert.strictEqual(post.status, 303);++ assert.strictEqual(post.headers.get('location'), '/me');++ const page = await t.get('/u/goosely');++ assert.ok(page.text.includes('Things made') && page.text.includes('id="rp-u-ai"') && page.text.includes('Vibes'), 'all five published');++ const me = await t.get('/me', { headers: auth });++ assert.ok(me.text.includes('Public now'));++ const sitemap = await t.get('/sitemap.xml');++ assert.ok(sitemap.text.includes('https://openvibe.report/u/goosely'), 'the public page is in the sitemap');++ });++++ await check('the switches are same-origin and the person is never taken from the query', async () => {++ const cross = await t.get('/api/v1/me/stats/published', {++ method: 'PUT',++ headers: { ...auth, 'content-type': 'application/json' },++ json: { published: { sites: true, activity: true, made: true, ai: true, money: true } },++ });++ assert.strictEqual(cross.status, 403);++ const bad = await t.get('/api/v1/me/stats/published', {++ method: 'PUT',++ headers: { ...auth, 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },++ json: { published: { sites: true } },++ });++ assert.strictEqual(bad.status, 422);++ const other = await t.get('/api/v1/me/stats?subject=usr_01JTESTGOOSELY0000000000000', { headers: { cookie: `ov_token=${quiet}` } });++ assert.strictEqual(other.json().person.username, 'quiet');++ assert.strictEqual(viewed[viewed.length - 1].subject, 'usr_01JTESTQUIET00000000000000');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -79,6 +79,8 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through
@@ -79,6 +79,8 @@ All server-rendered in `server/views/index.js`; every dynamic value goes through
|---|---|
|---|---|
| `/` | the 7-day open numbers, the Rundown's counts and lead, and the trending topics |
| `/` | the 7-day open numbers, the Rundown's counts and lead, and the trending topics |
| `/stats?days=7\|30\|90` | the network totals and a first look at every site (anything else is 30) |
| `/stats?days=7\|30\|90` | the network totals and a first look at every site (anything else is 30) |
+| `/me?days=7\|30\|90` | **signed in only, private, noindex**: your sites and activity (Network's history), what you made, your AI and Vibes, with a switch per section to publish it (the owner, 2026-11-09) |
+| `/u/:username` | a person's published stats: only the sections they turned on, with Person JSON-LD; noindex and no numbers until they publish |
| `/rundown` | on air, the lead, the latest streams and every topic; canonical is OpenVibe.Live's `/rundown` |
| `/rundown` | on air, the lead, the latest streams and every topic; canonical is OpenVibe.Live's `/rundown` |
| `/rundown/t/:slug` | one topic, its takes and related topics; canonical is Live's `/rundown/t/:slug` |
| `/rundown/t/:slug` | one topic, its takes and related topics; canonical is Live's `/rundown/t/:slug` |
| `/rundown/e/:streamId` | one stream as an episode, with its segments; canonical is Live's `/rundown/e/:streamId` |
| `/rundown/e/:streamId` | one stream as an episode, with its segments; canonical is Live's `/rundown/e/:streamId` |
@@ -111,10 +113,16 @@ GET /api/v1/rundown/t/:slug
@@ -111,10 +113,16 @@ GET /api/v1/rundown/t/:slug
GET /api/v1/after-show/:streamId
GET /api/v1/after-show/:streamId
GET /api/v1/c/:username
GET /api/v1/c/:username
GET /api/v1/ai/results the new AI runs, newest first
GET /api/v1/ai/results the new AI runs, newest first
+GET /api/v1/me/stats?days=7|30|90 the signed-in person's own numbers (private, no-store)
+PUT /api/v1/me/stats/published the five per-section switches (same-origin, JSON)
+GET /api/v1/u/:name/stats only the sections that person published (404 when none)
```
```
A bad parameter or a missing source answer is a 404 `application/problem+json` body built with
A bad parameter or a missing source answer is a 404 `application/problem+json` body built with
-`openvibe-contracts` `http.sendProblem`. The public API is read-only; other methods get 405.
+`openvibe-contracts` `http.sendProblem`. The API is read-only except for the person's own publish switches; other
+methods get 405. `GET /api/v1/me/stats` and `PUT /api/v1/me/stats/published` answer the `report.me-stats@1` document
+(the switch request is `report.me-stats-publish@1`); the public route copies only the published sections from the
+person's last snapshot, so a section a source did not answer for is absent, never a zero.
The AI Exchange JSON read is the staff exception (with `REPORT_AI_EXCHANGE=1`): a Network service token with the
The AI Exchange JSON read is the staff exception (with `REPORT_AI_EXCHANGE=1`): a Network service token with the
same `report.ai_usage.read` capability the AI meter uses, never cached, no person or project in the answer. The
same `report.ai_usage.read` capability the AI meter uses, never cached, no person or project in the answer. The
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+-- phase: expand
+-- A person's own numbers (the owner, 2026-11-09): "you can make them public if you want, but private by default."
+--
+-- One row per subject holds the five publish switches (published, all false until the person turns one on) and the
+-- last computed snapshot (report.me-stats@1). The snapshot is the private document: the owner's /me, GET
+-- /api/v1/me/stats and the per-section switches write it. The public /u/<name> page and GET /api/v1/u/:name/stats
+-- read it through server/me-stats.js, which copies only the sections the switches name; the row itself is never
+-- returned to a reader.
+--
+-- Why a snapshot and not a live count on the public page: the sites and the activity come from Network's cross-site
+-- history, which only the person's own session token may read. A public page has no token, so it shows the snapshot
+-- the owner last computed, with its generated_at. A section whose source did not answer is absent from the snapshot,
+-- never a zero.
+--
+-- The display name and avatar are stored so a public page still names the person when the snapshot is old. Usernames
+-- change on Network: the row is re-keyed by subject on every visit and matched case-insensitively by the current name.
+--
+-- Expand only: one table and its indexes; nothing that exists is changed or dropped, and an older release keeps
+-- reading its own tables exactly as before.
+CREATE TABLE IF NOT EXISTS report_me_stats (
+ subject text COLLATE "C" PRIMARY KEY,
+ username text NOT NULL,
+ display_name text NOT NULL,
+ avatar_url text,
+ published jsonb NOT NULL DEFAULT '{}'::jsonb,
+ snapshot jsonb,
+ snapshot_at timestamptz,
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS report_me_stats_username ON report_me_stats (lower(username));
+CREATE INDEX IF NOT EXISTS report_me_stats_updated ON report_me_stats (updated_at DESC);
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.362.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
@@ -1057,9 +1057,9 @@
@@ -1057,9 +1057,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.338.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",
+ "version": "0.362.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.362.0",
+ "integrity": "sha512-Dx+wnwTVSA90fZsjnCv8VAF3G6PI+vgFO8roRhKkAQujnKy6Ee82w9xcU07k0QbDYjXvJuVxw+VCnxMNNhT3Uw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.362.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
@@ -386,3 +386,49 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -386,3 +386,49 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
/* A table inside a kit section (the AI page's leaders and benchmarks): the section is the card, so the table's own
/* A table inside a kit section (the AI page's leaders and benchmarks): the section is the card, so the table's own
frame goes (it drew a card inside a card, one border inside another). */
frame goes (it drew a card inside a card, one border inside another). */
.ovk-section > .ovk-table-wrap { border: 0; border-radius: 0; background: transparent; box-shadow: none; }
.ovk-section > .ovk-table-wrap { border: 0; border-radius: 0; background: transparent; box-shadow: none; }
+
+/* Your stats (designed 11-09): sites as bars, twelve weeks as a heatmap, sharing as switches after the numbers. */
+.rp-sites { list-style: none; max-width: 760px; margin: 4px 0 10px; padding: 0; display: grid; gap: 2px; }
+.rp-sites li { display: grid; grid-template-columns: minmax(84px, 9rem) minmax(0, 1fr) auto; grid-template-rows: auto auto; align-items: center; column-gap: 12px; padding: 8px 4px; border-radius: 10px; }
+.rp-sites li > a { grid-row: 1 / 3; font-weight: 700; color: inherit; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.rp-sites li > a:hover { color: var(--accent, #3b82f6); }
+.rp-sbar { display: block; height: 10px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); overflow: hidden; }
+.rp-sbar i { display: block; width: var(--p); height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent-strong, #2563eb), var(--accent, #3b82f6)); transform-origin: left; animation: rp-grow .8s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i, 0) * 50ms); }
+.rp-sites li > b { font-size: 1.05rem; font-variant-numeric: tabular-nums; text-align: right; }
+.rp-sites li > small { grid-column: 2 / 4; font-size: .78rem; color: var(--text-muted, #7386a3); }
+@keyframes rp-grow { from { transform: scaleX(0); } }
+.rp-heat { display: flex; gap: 6px; margin: 18px 0 6px; overflow-x: auto; }
+.rp-heat-days, .rp-heat-col { display: grid; grid-template-rows: repeat(7, 1fr); gap: 4px; }
+.rp-heat-days i { font-style: normal; font-size: .66rem; line-height: 1; color: var(--text-muted, #7386a3); display: flex; align-items: center; min-height: 0; }
+.rp-heat-grid { flex: 0 1 auto; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(14px, 24px); gap: 4px; }
+.rp-heat-col i, .rp-heat-scale i { display: block; aspect-ratio: 1; min-width: 12px; border-radius: 4px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); }
+.rp-heat-col i.l1, .rp-heat-scale i.l1 { background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
+.rp-heat-col i.l2, .rp-heat-scale i.l2 { background: color-mix(in srgb, var(--accent, #3b82f6) 52%, transparent); }
+.rp-heat-col i.l3, .rp-heat-scale i.l3 { background: color-mix(in srgb, var(--accent, #3b82f6) 76%, transparent); }
+.rp-heat-col i.l4, .rp-heat-scale i.l4 { background: var(--accent, #3b82f6); }
+.rp-heat-key { display: flex; justify-content: space-between; align-items: center; gap: 10px; max-width: 360px; margin: 0 0 14px 34px; font-size: .74rem; color: var(--text-muted, #7386a3); }
+.rp-heat-scale { display: inline-flex; align-items: center; gap: 3px; }
+.rp-heat-scale i { width: 11px; min-width: 11px; }
+.rp-fold { margin: 6px 0 4px; }
+.rp-fold > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; font-weight: 700; color: var(--text-secondary, #96a7c2); }
+.rp-fold > summary span { font-weight: 600; color: var(--text-muted, #7386a3); font-size: .85rem; }
+.rp-privacy { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; width: fit-content; margin: 4px 0 18px; padding: 8px 14px; border-radius: 999px; font-size: .88rem; font-weight: 650;
+ background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
+.rp-privacy span::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: var(--text-muted, #7386a3); vertical-align: 1px; }
+.rp-privacy.is-public span::before { background: var(--success, #22c55e); }
+.rp-privacy a { color: var(--accent, #3b82f6); }
+.rp-share fieldset { margin: 0; padding: 0; border: 0; }
+.rp-share ul { list-style: none; margin: 0 0 14px; padding: 0; display: grid; gap: 4px; }
+.rp-share .rp-publish-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 2px 4px; border-radius: 12px; }
+.rp-share .rp-publish-row label { flex: 1; display: flex; align-items: center; gap: 12px; min-height: 48px; cursor: pointer; }
+.rp-share .rp-publish-row > .rp-fine { margin: 0; flex: none; font-size: .8rem; }
+.rp-switch { appearance: none; -webkit-appearance: none; flex: none; position: relative; width: 44px; height: 26px; margin: 0; border-radius: 999px; cursor: pointer;
+ background: color-mix(in srgb, var(--text-primary, #e6edf7) 16%, transparent); transition: background .2s; }
+.rp-switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .2s cubic-bezier(.2, .9, .3, 1.2); }
+.rp-switch:checked { background: var(--success, #22c55e); }
+.rp-switch:checked::after { transform: translateX(18px); }
+.rp-switch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+.rp-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+@media (max-width: 520px) { .rp-sites li { grid-template-columns: minmax(70px, 6.5rem) minmax(0, 1fr) auto; column-gap: 10px; } }
+@media (prefers-reduced-motion: reduce) { .rp-sbar i { animation: none; } .rp-switch, .rp-switch::after { transition: none; } }
+.rp-share-card { margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--border, rgba(255,255,255,.1)); }
@@ -7,6 +7,8 @@
@@ -7,6 +7,8 @@
* /, /stats, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username,
* /, /stats, /rundown, /rundown/t/:slug, /rundown/e/:streamId, /rundown/s/:username,
* /after-show/:streamId, /c/:username the pages (http/pages.js)
* /after-show/:streamId, /c/:username the pages (http/pages.js)
* /you your own numbers, signed in (plan L41 R1)
* /you your own numbers, signed in (plan L41 R1)
+ * /me, /u/:username a person's stats: private by default, the published
+ * sections at /u (the owner, 2026-11-09)
* /auth/* sign-in with OpenVibe.Network (openvibe-sdk/sso)
* /auth/* sign-in with OpenVibe.Network (openvibe-sdk/sso)
* /ai, /ai/models/:model, /ai/:kind the AI boards, from OpenVibe.Bench (http/pages.js)
* /ai, /ai/models/:model, /ai/:kind the AI boards, from OpenVibe.Bench (http/pages.js)
* /api/v1/* the JSON API (http/api.js)
* /api/v1/* the JSON API (http/api.js)
@@ -16,7 +18,8 @@
@@ -16,7 +18,8 @@
* Report has its own database (server/db.js, migrations/) for the analytics it ingests, and reads Billing's money
* Report has its own database (server/db.js, migrations/) for the analytics it ingests, and reads Billing's money
* through Billing's read API. The pages and GET /api/v1/stats answer from REPORT_SOURCE (network by default,
* through Billing's read API. The pages and GET /api/v1/stats answer from REPORT_SOURCE (network by default,
* report after cutover); Report's tables are written by the ingest job and by OpenVibe.Events. The only session is
* report after cutover); Report's tables are written by the ingest job and by OpenVibe.Events. The only session is
- * the person's own sign-in (openvibe-sdk/sso, the `report` client): it reads /you and nothing else.
+ * the person's own sign-in (openvibe-sdk/sso, the `report` client): it reads /you, /me and the /u/<name> page a
+ * person chose to publish; the only write is the per-section publish switch (PUT/POST /api/v1/me/stats/published).
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -38,6 +41,7 @@ const { createAiExchange } = require('./analytics/ai-exchange');
@@ -38,6 +41,7 @@ const { createAiExchange } = require('./analytics/ai-exchange');
const createAiExchangeConfig = require('./ai-exchange-config').create;
const createAiExchangeConfig = require('./ai-exchange-config').create;
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
const { createSessionReports } = require('./session-reports');
const { createSessionReports } = require('./session-reports');
+const { createMeStats } = require('./me-stats');
const { createSessionReportRoutes } = require('./http/session-reports');
const { createSessionReportRoutes } = require('./http/session-reports');
const sharing = require('./sharing');
const sharing = require('./sharing');
const { createShadow } = require('./analytics/shadow');
const { createShadow } = require('./analytics/shadow');
@@ -102,6 +106,10 @@ async function createApp(opts = {}) {
@@ -102,6 +106,10 @@ async function createApp(opts = {}) {
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
// The report of every agent session (Agent Streams S3): one table, one route, one page.
// The report of every agent session (Agent Streams S3): one table, one route, one page.
const sessionReports = opts.sessionReports || (store ? createSessionReports({ store, config, log }) : null);
const sessionReports = opts.sessionReports || (store ? createSessionReports({ store, config, log }) : null);
+ // A person's own numbers (the owner, 2026-11-09): the five sections and the per-section publish switches. The
+ // snapshot is stored here; the public page reads only what the person published. With no store, /me computes but
+ // nothing can be published.
+ const meStats = opts.meStats || createMeStats({ store, network, analytics, aiUsage, sessionReports, config, now, log });
// The sharing system (openvibe-sdk/share, share.v1): a report's four audiences live in its share row, the owner's
// The sharing system (openvibe-sdk/share, share.v1): a report's four audiences live in its share row, the owner's
// Share sheet changes it, and every read path asks canView (server/sharing.js). Built before the session-report
// Share sheet changes it, and every read path asks canView (server/sharing.js). Built before the session-report
// writer, so the first write registers its row.
// writer, so the first write registers its row.
@@ -118,7 +126,7 @@ async function createApp(opts = {}) {
@@ -118,7 +126,7 @@ async function createApp(opts = {}) {
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
});
});
- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports };
+ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports, meStats };
const app = express();
const app = express();
if (exchangeConfig) exchangeConfig.mount(app);
if (exchangeConfig) exchangeConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@ -224,6 +232,28 @@ async function createApp(opts = {}) {
@@ -224,6 +232,28 @@ async function createApp(opts = {}) {
// pinned openvibe-sdk predates `share`: then only the old column's three levels work, and a report nobody may open
// pinned openvibe-sdk predates `share`: then only the old column's three levels work, and a report nobody may open
// is a 404 — fail closed.
// is a 404 — fail closed.
app.use(shareRouterFor({ log }));
app.use(shareRouterFor({ log }));
+ // The publish switches as a plain form (the /me page's "Make my stats public" rows), so the page works whole
+ // without JavaScript. Same origin only; signed in only; answers /me again with the new switches in force. The JSON
+ // PUT for apps and the menu lives in the read API below.
+ app.post('/api/v1/me/stats/published', express.urlencoded({ extended: false, limit: '4kb' }), async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ const user = req.user;
+ if (!user || !user.subject_id) return res.redirect(303, '/auth/login?next=%2Fme');
+ if (!req.headers['x-requested-with'] && !['same-origin', 'same-site'].includes(String(req.headers['sec-fetch-site'] || ''))) {
+ return contracts.http.sendProblem(res, 403, 'report.csrf', { detail: 'Cross-site request refused', ctx: req.ov });
+ }
+ const published = {};
+ for (const key of meStats.SECTIONS) {
+ const raw = req.body && req.body[key];
+ published[key] = (Array.isArray(raw) ? raw[raw.length - 1] : raw) === 'true';
+ }
+ try {
+ await meStats.setPublished({ user, token: req.token, published });
+ } catch (err) {
+ log.error('[OpenVibe.Report] the stats switches failed:', err && err.message ? String(err.message).slice(0, 200) : err);
+ }
+ return res.redirect(303, '/me');
+ });
// The owner's default for the reports they make from now on (their own page, /you). Signed in only, same-origin
// The owner's default for the reports they make from now on (their own page, /you). Signed in only, same-origin
// only, and it changes nothing about a report that already exists — the sheet changes those one by one.
// only, and it changes nothing about a report that already exists — the sheet changes those one by one.
app.post('/api/v1/me/session-reports/default', express.json({ limit: '2kb' }), async (req, res) => {
app.post('/api/v1/me/session-reports/default', express.json({ limit: '2kb' }), async (req, res) => {
@@ -289,7 +319,7 @@ async function createApp(opts = {}) {
@@ -289,7 +319,7 @@ async function createApp(opts = {}) {
legacyHeaders: false,
legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}));
}));
- app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, config, now, log }));
+ app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, meStats, config, now, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Crawl artifacts, then the pages ─────────────────────
// ── Crawl artifacts, then the pages ─────────────────────
@@ -30,7 +30,23 @@ function createNetworkClient({ config = {}, fetchImpl = globalThis.fetch, log =
@@ -30,7 +30,23 @@ function createNetworkClient({ config = {}, fetchImpl = globalThis.fetch, log =
return cache.set(window, data, STATS_TTL_MS);
return cache.set(window, data, STATS_TTL_MS);
}
}
- return { stats, cache, baseUrl, ttlMs: STATS_TTL_MS };
+ /**
+ * The person's own cross-site history (GET /api/history), with their own Network session token. Report's sign-in
+ * token is a Network user session (openvibe-sdk/sso, audience openvibe.network + report), so Network answers it.
+ * Returns the rows, or null when Network cannot answer. Never cached: it is one person's and changes.
+ */
+ async function history(token, { limit = 200 } = {}) {
+ const bearer = typeof token === 'string' ? token.trim() : '';
+ if (!bearer) return null;
+ const cap = Math.max(1, Math.min(200, Number(limit) || 200));
+ const data = await requestJson(`${baseUrl}/api/history?limit=${cap}`, {
+ fetchImpl, log, timeoutMs, label: 'network history', headers: { Authorization: `Bearer ${bearer}` },
+ });
+ if (!data || !Array.isArray(data.items)) return null;
+ return data.items;
+ }
+
+ return { stats, history, cache, baseUrl, ttlMs: STATS_TTL_MS };
}
}
module.exports = { createNetworkClient, STATS_TTL_MS };
module.exports = { createNetworkClient, STATS_TTL_MS };
@@ -31,15 +31,16 @@ function warn(log, message) {
@@ -31,15 +31,16 @@ function warn(log, message) {
/**
/**
* GET a JSON document with an 8 s timeout. Returns the parsed body, or null after one log line.
* GET a JSON document with an 8 s timeout. Returns the parsed body, or null after one log line.
- * A 404 is the source answering "no such thing", so it is null without a log line.
+ * A 404 is the source answering "no such thing", so it is null without a log line. `headers` carries a caller's
+ * own Authorization for the routes that answer one person (Network's history); it is never logged.
*/
*/
-async function requestJson(url, { fetchImpl = globalThis.fetch, log = console, label = 'source', timeoutMs = TIMEOUT_MS } = {}) {
+async function requestJson(url, { fetchImpl = globalThis.fetch, log = console, label = 'source', timeoutMs = TIMEOUT_MS, headers = null } = {}) {
let res;
let res;
try {
try {
const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
? AbortSignal.timeout(timeoutMs)
? AbortSignal.timeout(timeoutMs)
: undefined;
: undefined;
- res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });
+ res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(headers || {}) }, signal });
} catch (err) {
} catch (err) {
warn(log, `${label} failed: ${reasonOf(err)}`);
warn(log, `${label} failed: ${reasonOf(err)}`);
return null;
return null;
@@ -15,8 +15,9 @@
@@ -15,8 +15,9 @@
* that answered nothing. Every /api/v1 answer is CORS-open and public for two minutes; the app mounts the 120/min
* that answered nothing. Every /api/v1 answer is CORS-open and public for two minutes; the app mounts the 120/min
* per-address limit in front of this router.
* per-address limit in front of this router.
*
*
- * GET /api/v1/me/ai/usage is the exception: it answers only the person their session names, so it is private, never
- * cached, and has its own tighter per-address limit.
+ * GET /api/v1/me/ai/usage and GET /api/v1/me/stats answer only the person their session names, so they are private,
+ * never cached, and have their own tighter per-address limit. PUT /api/v1/me/stats/published is the one write; GET
+ * /api/v1/u/:name/stats is public and carries only the sections that person published.
*/
*/
const express = require('express');
const express = require('express');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
@@ -30,17 +31,33 @@ const sharing = require('../sharing');
@@ -30,17 +31,33 @@ const sharing = require('../sharing');
const CACHE = 'public, max-age=120';
const CACHE = 'public, max-age=120';
-function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, config = {}, now = () => Date.now(), log = console }) {
+function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, meStats = null, config = {}, now = () => Date.now(), log = console }) {
const r = express.Router();
const r = express.Router();
const serviceAuth = createServiceAuth({ config, now });
const serviceAuth = createServiceAuth({ config, now });
+ // The one write: the person's own publish switches. A browser sends a same-origin request (or an
+ // X-Requested-With); a Bearer caller is not a cookie, so it needs no CSRF token. Everything else is read-only.
+ const isPublishWrite = (req) => req.method === 'PUT' && (req.path === '/me/stats/published' || req.path === '/me/stats/published/');
+ function sameOrigin(req) {
+ if (String((req.headers && req.headers.authorization) || '').startsWith('Bearer ')) return true;
+ const site = String((req.headers && req.headers['sec-fetch-site']) || '');
+ return req.headers['x-requested-with'] === 'fetch' || site === 'same-origin' || site === 'same-site';
+ }
+
r.use((req, res, next) => {
r.use((req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Origin', '*');
res.set('Cache-Control', CACHE);
res.set('Cache-Control', CACHE);
- if (req.method !== 'GET' && req.method !== 'HEAD') {
+ if (req.method === 'OPTIONS' && req.path === '/me/stats/published') {
+ res.set('Access-Control-Allow-Methods', 'GET, HEAD, PUT, OPTIONS');
+ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
+ return res.status(204).end();
+ }
+ if (req.method !== 'GET' && req.method !== 'HEAD' && !isPublishWrite(req)) {
res.set('Allow', 'GET, HEAD');
res.set('Allow', 'GET, HEAD');
return problem(req, res, 405, 'method.not_allowed', 'This API is read-only; use GET.');
return problem(req, res, 405, 'method.not_allowed', 'This API is read-only; use GET.');
}
}
+ res.set('Access-Control-Allow-Methods', 'GET, HEAD, PUT, OPTIONS');
+ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
return next();
return next();
});
});
@@ -215,6 +232,66 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
@@ -215,6 +232,66 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
return res.json(recaps);
return res.json(recaps);
});
});
+ // ── A person's own numbers (the owner, 2026-11-09) ─────────────────────────────────────────────────
+ //
+ // GET /api/v1/me/stats answers the person the session names, with every section a source answered for. The
+ // per-section switches are the person's own; a section is absent until a source gives it, never a zero. A public
+ // reader gets only what the person published, from their last snapshot (GET /api/v1/u/:name/stats).
+ r.get('/me/stats', async (req, res) => {
+ const user = req.user;
+ if (!user || !user.subject_id) return problem(req, res, 401, 'auth.sign_in_required', 'Sign in to see your own numbers.');
+ if (!meStats) return problem(req, res, 503, 'report.no_me_stats', 'Your numbers are not available in this process.');
+ try {
+ const body = await meStats.compute({ user, token: req.token, days: normaliseDays(req.query.days) });
+ return res.json(body);
+ } catch (err) {
+ log.error('[OpenVibe.Report] the personal stats failed:', err && err.message ? String(err.message).slice(0, 200) : err);
+ return problem(req, res, 500, 'report.me_stats_failed', 'Your numbers could not be read.');
+ }
+ });
+
+ // The per-section switch. It is the one route that changes something: it writes only this subject's row and
+ // answers the recomputed document, so the page can show what is public now.
+ r.put('/me/stats/published', express.json({ limit: '4kb' }), async (req, res) => {
+ const user = req.user;
+ if (!user || !user.subject_id) return problem(req, res, 401, 'auth.sign_in_required', 'Sign in to publish your numbers.');
+ if (!sameOrigin(req)) return problem(req, res, 403, 'report.csrf', 'Cross-site request refused.');
+ if (!meStats) return problem(req, res, 503, 'report.no_me_stats', 'Your numbers are not available in this process.');
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ const published = body.published;
+ if (!published || typeof published !== 'object' || Array.isArray(published)) {
+ return problem(req, res, 422, 'report.me_stats_invalid', 'published must name every section.');
+ }
+ const switches = {};
+ for (const key of meStats.SECTIONS) {
+ if (typeof published[key] !== 'boolean') return problem(req, res, 422, 'report.me_stats_invalid', `published.${key} must be true or false.`);
+ switches[key] = published[key];
+ }
+ try {
+ const out = await meStats.setPublished({ user, token: req.token, days: normaliseDays(body.days), published: switches });
+ return res.json(out);
+ } catch (err) {
+ log.error('[OpenVibe.Report] the stats switches failed:', err && err.message ? String(err.message).slice(0, 200) : err);
+ return problem(req, res, 500, 'report.me_stats_failed', 'Your public settings could not be saved.');
+ }
+ });
+
+ // A person's published numbers. Only the sections they published are here; a person who has published nothing is
+ // a 404 to a stranger, exactly as if they had never made a page. The answer is the owner's last snapshot.
+ r.get('/u/:name/stats', async (req, res) => {
+ const name = String(req.params.name || '');
+ if (!isUsername(name)) return notFound(req, res, 'report.person_stats_not_found', 'No such person.');
+ if (!meStats) return problem(req, res, 503, 'report.no_me_stats', 'Public numbers are not available in this process.');
+ try {
+ const body = await meStats.publicFor(name);
+ if (!body || !meStats.anyPublished(body.published)) return notFound(req, res, 'report.person_stats_not_found', 'No public numbers for that person.');
+ return res.json(body);
+ } catch (err) {
+ log.error('[OpenVibe.Report] the public stats failed:', err && err.message ? String(err.message).slice(0, 200) : err);
+ return problem(req, res, 500, 'report.me_stats_failed', 'Those numbers could not be read.');
+ }
+ });
+
// The caller's own session reports of the last days (Today's "Your agents this week", S7). The list is the
// The caller's own session reports of the last days (Today's "Your agents this week", S7). The list is the
// person's alone: a signed-out reader is 401, never a list of everybody's public reports.
// person's alone: a signed-out reader is 401, never a list of everybody's public reports.
r.get('/me/session-reports', async (req, res) => {
r.get('/me/session-reports', async (req, res) => {
@@ -32,7 +32,7 @@ function siteUpdated() {
@@ -32,7 +32,7 @@ function siteUpdated() {
}
}
}
}
-function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {
+function createDiscoveryRoutes({ config, live, bench, sessionReports = null, meStats = null }) {
const r = express.Router();
const r = express.Router();
const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');
const site = String(config.baseUrl || 'https://openvibe.report').replace(/\/+$/, '');
const abs = (p) => `${site}${p}`;
const abs = (p) => `${site}${p}`;
@@ -46,8 +46,9 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {
@@ -46,8 +46,9 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {
});
});
r.get('/sitemap.xml', async (_req, res) => {
r.get('/sitemap.xml', async (_req, res) => {
- const [ai, overview, reports] = await Promise.all([
+ const [ai, overview, reports, people] = await Promise.all([
bench.kinds(), bench.overview(), sessionReports ? sessionReports.listPublic({ limit: 500 }) : [],
bench.kinds(), bench.overview(), sessionReports ? sessionReports.listPublic({ limit: 500 }) : [],
+ meStats ? meStats.listPublished({ limit: 500 }).catch(() => []) : [],
]);
]);
const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];
const kinds = (ai && Array.isArray(ai.data.kinds)) ? ai.data.kinds : [];
const o = overview && overview.data ? overview.data : {};
const o = overview && overview.data ? overview.data : {};
@@ -67,6 +68,12 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {
@@ -67,6 +68,12 @@ function createDiscoveryRoutes({ config, live, bench, sessionReports = null }) {
{ loc: abs('/ai/method'), changefreq: 'weekly', priority: 0.6 },
{ loc: abs('/ai/method'), changefreq: 'weekly', priority: 0.6 },
{ loc: abs('/ai/compare'), changefreq: 'weekly', priority: 0.6 },
{ loc: abs('/ai/compare'), changefreq: 'weekly', priority: 0.6 },
...reports.map((rep) => ({ loc: abs('/s/' + encodeURIComponent(rep.id)), changefreq: 'daily', priority: 0.6 })),
...reports.map((rep) => ({ loc: abs('/s/' + encodeURIComponent(rep.id)), changefreq: 'daily', priority: 0.6 })),
+ // A person's public stats page is crawlable only when they published a section; the private default is
+ // never in the sitemap. The last modification is the snapshot they last computed.
+ ...people.filter((p) => p && p.username).map((p) => ({
+ loc: abs(`/u/${encodeURIComponent(p.username)}`), changefreq: 'weekly', priority: 0.5,
+ ...(dayOf(p.generated_at) ? { lastmod: dayOf(p.generated_at) } : {}),
+ })),
...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({
...kinds.filter((k) => k && bench.isKindId(k.id)).map((k) => ({
loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,
loc: abs(`/ai/${encodeURIComponent(k.id)}`), changefreq: 'daily', priority: 0.7,
})),
})),
@@ -416,6 +416,32 @@ function createPageRoutes(ctx) {
@@ -416,6 +416,32 @@ function createPageRoutes(ctx) {
return send(req, res, views.recap({ recap }));
return send(req, res, views.recap({ recap }));
});
});
+ // A person's stats (the owner, 2026-11-09): /me is private and signed in only, with a switch per section; /u/<name>
+ // is the same document with only the sections the person made public. Both are server-rendered, so the words and
+ // the numbers are crawlable when published; /me is never indexed. The public page is the owner's last snapshot,
+ // dated in the page, because Network's history can only be read with the person's own token.
+ r.get('/me', async (req, res) => {
+ const user = req.user;
+ if (!user || !isUsername(String(user.username || ''))) return send(req, res, views.meSignedOut(), 200, 'private, no-store');
+ const days = normaliseDays(req.query.days);
+ try {
+ const doc = await ctx.meStats.compute({ user, token: req.token, days });
+ return send(req, res, views.me({ user, doc, days }), 200, 'private, no-store');
+ } catch (err) {
+ ctx.log.error('[OpenVibe.Report] the /me page failed:', err && err.message ? String(err.message).slice(0, 200) : err);
+ return send(req, res, views.meError({ user }), 500, 'private, no-store');
+ }
+ });
+
+ r.get('/u/:username', async (req, res) => {
+ const { username } = req.params;
+ if (!isUsername(username)) return notFound(req, res);
+ const doc = ctx.meStats ? await ctx.meStats.publicFor(username) : null;
+ if (!doc) return notFound(req, res);
+ const published = ctx.meStats.anyPublished(doc.published);
+ return send(req, res, published ? views.publicStats({ doc }) : views.publicStatsPrivate({ doc }), 200, published ? CACHE : 'private, no-store');
+ });
+
// A person's own numbers (plan L41 R1). Signed out: what it is and a sign-in. Signed in: their channel's streams and
// A person's own numbers (plan L41 R1). Signed out: what it is and a sign-in. Signed in: their channel's streams and
// after-show reports, the payments Billing says reached them, and The Rundown, over 7, 30 or 90 days. Private.
// after-show reports, the payments Billing says reached them, and The Rundown, over 7, 30 or 90 days. Private.
r.get('/you', async (req, res) => {
r.get('/you', async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,339 @@
@@ -0,0 +1,339 @@
+'use strict';
+
+/**
+ * A person's own numbers (the owner, 2026-11-09): "a section for most used, your most used platforms across all of
+ * them, with OpenVibe.Report analysis for you… show your stats… you can make them public if you want, but private by
+ * default… full stats and graphs… and it shows most used in here."
+ *
+ * The sections, each read from the source that owns the number:
+ * sites Network's cross-site history (GET /api/history with the person's own session token): the sites they
+ * opened this week and this month, most visited first, and the top eight for the menu.
+ * activity the same history as days: twelve weeks, the days they were around, and the streak.
+ * made Report's own Live analytics (streams and hours) and the person's session reports.
+ * ai Report's AI meter (report.ai-usage-me-result@2): calls, tokens and what the meter says they cost.
+ * money Billing's read API, through the analytics client: the Vibes that reached the person.
+ *
+ * A source that does not answer leaves its section out; it is never turned into a zero. The document is stored as a
+ * snapshot at /me: the public /u/<name> page and GET /api/v1/u/:name/stats serve that snapshot with only the sections
+ * the person published. Nothing unpublished is ever put in a public answer.
+ *
+ * Network's history counts visits, not durations: the same page opened twice within ten minutes is one visit, and
+ * there is no minutes field to invent. The contract says so too (report.me-stats@1).
+ */
+const DAY = 86_400_000;
+const SECTIONS = ['sites', 'activity', 'made', 'ai', 'money'];
+const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
+const SERVICE_RE = /^[a-z0-9-]{1,32}$/;
+const HEAT_WEEKS = 12;
+const TOP_SITES = 8;
+const HISTORY_LIMIT = 200;
+
+const n = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
+const int = (v) => { const x = n(v); return x == null ? null : Math.max(0, Math.round(x)); };
+const iso = (ms) => new Date(ms).toISOString();
+const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
+const startOfDay = (ms) => Date.UTC(new Date(ms).getUTCFullYear(), new Date(ms).getUTCMonth(), new Date(ms).getUTCDate());
+
+/** A history timestamp, which Network may send as SQLite's "YYYY-MM-DD HH:MM:SS" (UTC) or as ISO. */
+function timeOf(value) {
+ if (value == null) return null;
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
+ const s = String(value);
+ const withZone = /[TZ]/.test(s) || !s.includes(' ') ? s : `${s.replace(' ', 'T')}Z`;
+ const t = Date.parse(withZone);
+ return Number.isFinite(t) ? t : null;
+}
+
+function emptyPublished() {
+ return { sites: false, activity: false, made: false, ai: false, money: false };
+}
+
+/** The switches, always all five and always booleans. */
+function publishedOf(value) {
+ const out = emptyPublished();
+ if (!value || typeof value !== 'object') return out;
+ for (const k of SECTIONS) out[k] = value[k] === true || value[k] === 'true';
+ return out;
+}
+
+const anyPublished = (p) => SECTIONS.some((k) => p[k] === true);
+
+function jsonOf(value) {
+ if (value == null) return null;
+ if (typeof value === 'object') return value;
+ try { return JSON.parse(String(value)); } catch { return null; }
+}
+
+/** A public http(s) URL, or null. */
+function urlOf(value) {
+ try {
+ const u = new URL(String(value));
+ return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : null;
+ } catch { return null; }
+}
+
+function personOf(user) {
+ const username = String((user && user.username) || '').trim();
+ if (!USERNAME_RE.test(username)) return null;
+ const person = { username, display_name: String((user && (user.display_name || user.username)) || username).slice(0, 80) };
+ const avatar = urlOf(user && user.avatar_url);
+ if (avatar) person.avatar_url = avatar;
+ return person;
+}
+
+function usernameOf(user) {
+ const u = String((user && user.username) || '').trim();
+ return USERNAME_RE.test(u) ? u : null;
+}
+
+/**
+ * The sites and the days from Network's history rows. Answers null when the history is unavailable, so the caller
+ * leaves both sections out rather than claiming nothing happened.
+ */
+function fromHistory(items, now, days) {
+ if (!Array.isArray(items)) return null;
+ const today = startOfDay(now);
+ const from = today - (Math.max(1, days) - 1) * DAY;
+ const weekStart = today - 6 * DAY;
+ const monthStart = today - 29 * DAY;
+ const heatStart = today - (HEAT_WEEKS * 7 - 1) * DAY;
+ const countFrom = Math.min(heatStart, from);
+
+ const sites = new Map();
+ const byDay = new Map();
+ for (const item of items) {
+ const at = timeOf(item && (item.updated_at || item.created_at));
+ if (at == null) continue;
+ const service = String((item && item.service) || '').trim().toLowerCase();
+ if (!SERVICE_RE.test(service)) continue;
+ const url = urlOf(item && item.url);
+ let origin = url ? new URL(url).origin + '/' : null;
+ if (!origin) continue;
+ if (at >= countFrom) {
+ const day = dayOf(at);
+ byDay.set(day, (byDay.get(day) || 0) + 1);
+ }
+ if (at < monthStart) continue;
+ const hit = sites.get(service) || { service, label: '', url: origin, week: 0, month: 0, last: 0 };
+ hit.month += 1;
+ if (at >= weekStart) hit.week += 1;
+ if (at >= hit.last) {
+ hit.last = at;
+ hit.url = origin;
+ const label = String((item && item.service_label) || service);
+ hit.label = label.length > 60 ? label.slice(0, 60) : label;
+ }
+ sites.set(service, hit);
+ }
+
+ const all = [...sites.values()].sort((a, b) => b.month - a.month || a.service.localeCompare(b.service));
+ const week = all.filter((s) => s.week > 0).sort((a, b) => b.week - a.week || a.service.localeCompare(b.service)).map((s) => ({ service: s.service, label: s.label || s.service, url: s.url, visits: s.week }));
+ const month = all.map((s) => ({ service: s.service, label: s.label || s.service, url: s.url, visits: s.month }));
+ const top = month.slice(0, TOP_SITES);
+
+ // Twelve weeks, oldest first; each starts on its Monday (UTC).
+ const monday = today - ((new Date(today).getUTCDay() + 6) % 7) * DAY;
+ const weeks = [];
+ for (let w = HEAT_WEEKS - 1; w >= 0; w--) {
+ const start = monday - w * 7 * DAY;
+ const weekDays = [];
+ for (let d = 0; d < 7; d++) weekDays.push(byDay.get(dayOf(start + d * DAY)) || 0);
+ weeks.push({ week_start: dayOf(start), days: weekDays });
+ }
+ let activeDays = 0;
+ for (let t = from; t <= today; t += DAY) if (byDay.get(dayOf(t))) activeDays += 1;
+ let streak = 0;
+ for (let t = today; t >= from; t -= DAY) {
+ if (!byDay.get(dayOf(t))) break;
+ streak += 1;
+ }
+ return {
+ sites: { week, month, top },
+ activity: { weeks, active_days: activeDays, streak_days: streak },
+ };
+}
+
+/** One session report's creation time, or null. */
+function reportAt(report) {
+ return timeOf(report && (report.created_at || report.createdAt));
+}
+
+/**
+ * The client. `store` may be null (a network-only Report): /me still computes, but nothing is published and /u
+ * cannot answer. The network client, analytics, AI meter and session reports are the app's own.
+ */
+function createMeStats({ store = null, network = null, analytics = null, aiUsage = null, sessionReports = null, config = {}, now = () => Date.now(), log = console } = {}) {
+ const db = store && store.db ? store.db : null;
+ const clock = typeof now === 'function' ? now : () => Date.now();
+ const liveBase = String((config && config.livePublicUrl) || 'https://openvibe.live').replace(/\/+$/, '');
+ const base = String((config && config.baseUrl) || 'https://openvibe.report').replace(/\/+$/, '');
+
+ async function rowFor(subject) {
+ if (!db || !subject) return null;
+ try { return await db.prepare('SELECT * FROM report_me_stats WHERE subject = ?').get(String(subject)); } catch { return null; }
+ }
+
+ async function switchesFor(subject) {
+ const row = await rowFor(subject);
+ return publishedOf(row && jsonOf(row.published));
+ }
+
+ async function save({ subject, person, published, document }) {
+ if (!db || !subject) return;
+ await db.prepare(`INSERT INTO report_me_stats (subject, username, display_name, avatar_url, published, snapshot, snapshot_at, updated_at)
+ VALUES (?, ?, ?, ?, ?::jsonb, ?::jsonb, now(), now())
+ ON CONFLICT (subject) DO UPDATE SET
+ username = excluded.username, display_name = excluded.display_name, avatar_url = excluded.avatar_url,
+ published = excluded.published, snapshot = excluded.snapshot, snapshot_at = excluded.snapshot_at,
+ updated_at = now()`)
+ .run(String(subject), person.username, person.display_name, person.avatar_url || null, JSON.stringify(published), JSON.stringify(document));
+ }
+
+ /** Compute one person's document. `token` is their own Network session, forwarded only to Network's history. */
+ async function compute({ user, token = null, days = 30 } = {}) {
+ const person = personOf(user);
+ const subject = user && typeof user.subject_id === 'string' ? user.subject_id.trim() : '';
+ if (!person || !subject) throw new Error('me-stats: a signed-in person is required');
+ const windowDays = [7, 30, 90].includes(Number(days)) ? Number(days) : 30;
+ const at = clock();
+ const from = dayOf(at - (windowDays - 1) * DAY);
+ const published = await switchesFor(subject);
+
+ const notes = [];
+ const [history, stats, ai, reports] = await Promise.all([
+ network && typeof network.history === 'function' && token ? network.history(token, { limit: HISTORY_LIMIT }).catch(() => null) : Promise.resolve(null),
+ analytics && typeof analytics.channel === 'function' ? analytics.channel(person.username, windowDays).catch(() => null) : Promise.resolve(null),
+ aiUsage && typeof aiUsage.forPerson === 'function' ? aiUsage.forPerson({ subject, from, group: 'day' }).catch(() => null) : Promise.resolve(null),
+ sessionReports && typeof sessionReports.listForOwner === 'function' ? sessionReports.listForOwner(subject, { limit: 200 }).catch(() => null) : Promise.resolve(null),
+ ]);
+ if (network && typeof network.history === 'function' && token && history == null) notes.push('Your places and activity are not in this snapshot: OpenVibe.Network did not answer with your history.');
+ if (Array.isArray(history) && history.length >= HISTORY_LIMIT) notes.push('Your places and activity count the latest 200 pages in Network’s history.');
+ if (analytics && stats == null) notes.push('Your streams and Vibes are not in this snapshot: Report’s analytics did not answer.');
+
+ const sections = {};
+ const hist = fromHistory(history, at, windowDays);
+ if (hist) {
+ sections.sites = hist.sites;
+ sections.activity = hist.activity;
+ }
+
+ const totals = stats && stats.totals && typeof stats.totals === 'object' ? stats.totals : null;
+ const made = [];
+ if (totals) {
+ const streams = int(totals.streams);
+ const seconds = n(totals.stream_seconds);
+ if (streams > 0) made.push({ key: 'streams', label: 'Streams', count: streams, unit: 'streams', href: `${liveBase}/@${encodeURIComponent(person.username)}` });
+ if (seconds > 0) made.push({ key: 'stream_hours', label: 'Hours streamed', count: Math.round((seconds / 3600) * 10) / 10, unit: 'hours' });
+ }
+ if (Array.isArray(reports)) {
+ const fromAt = at - (windowDays - 1) * DAY;
+ const count = reports.filter((r) => { const t = reportAt(r); return t != null && t >= fromAt; }).length;
+ if (count > 0) made.push({ key: 'agent_reports', label: 'Agent reports', count, unit: 'reports', href: `${base}/you#rp-you-sr` });
+ }
+ if (made.length) sections.made = { items: made };
+
+ const aiTotals = ai && ai.totals && typeof ai.totals === 'object' ? ai.totals : null;
+ if (aiTotals) {
+ const calls = int(aiTotals.calls);
+ if (calls > 0) {
+ const tokens = (int(aiTotals.input_tokens) || 0) + (int(aiTotals.output_tokens) || 0);
+ const daily = (Array.isArray(ai.rows) ? ai.rows : []).map((r) => ({ day: String(r && r.key || '').slice(0, 10), calls: int(r && r.calls) || 0 }))
+ .filter((r) => /^\d{4}-\d{2}-\d{2}$/.test(r.day)).slice(-90);
+ sections.ai = { calls, tokens, cost_usd: Math.max(0, n(aiTotals.cost_usd) || 0), ...(daily.length ? { daily } : {}) };
+ }
+ }
+
+ const revenue = stats && stats.revenue && typeof stats.revenue === 'object' ? stats.revenue : null;
+ if (revenue) {
+ const transactions = int(revenue.transactions);
+ const settled = int(revenue.settled_bits);
+ if (transactions > 0 || settled > 0) {
+ sections.money = { currency: 'vibes-bits', settled_bits: settled || 0, transactions: transactions || 0 };
+ }
+ }
+
+ const document = {
+ generated_at: iso(at),
+ person,
+ window: { days: windowDays, from, to: dayOf(at) },
+ published,
+ sections: sectionsFor(sections, published, true),
+ ...(notes.length ? { note: notes.join(' ') } : {}),
+ };
+ await save({ subject, person, published, document }).catch((err) => {
+ try { (log.warn || log.error || console.warn).call(log, `[OpenVibe.Report] me-stats snapshot was not stored: ${(err && err.message) || 'database error'}`); } catch { /* */ }
+ });
+ return document;
+ }
+
+ async function setPublished({ user, token = null, days = 30, published } = {}) {
+ if (!db) throw new Error('me-stats: no database');
+ const person = personOf(user);
+ const subject = user && typeof user.subject_id === 'string' ? user.subject_id.trim() : '';
+ if (!person || !subject) throw new Error('me-stats: a signed-in person is required');
+ const switches = publishedOf(published);
+ await db.prepare(`INSERT INTO report_me_stats (subject, username, display_name, avatar_url, published, updated_at)
+ VALUES (?, ?, ?, ?, ?::jsonb, now())
+ ON CONFLICT (subject) DO UPDATE SET username = excluded.username, display_name = excluded.display_name,
+ avatar_url = excluded.avatar_url, published = excluded.published, updated_at = now()`)
+ .run(subject, person.username, person.display_name, person.avatar_url || null, JSON.stringify(switches));
+ // Recompute at the moment of the switch, so a section made public is current, not the last visit's.
+ return compute({ user, token, days });
+ }
+
+ /** The public document: the snapshot with only the published sections. null when there is no such person/row. */
+ async function publicFor(username) {
+ if (!db || !USERNAME_RE.test(String(username || ''))) return null;
+ let row;
+ try { row = await db.prepare('SELECT * FROM report_me_stats WHERE lower(username) = lower(?)').get(String(username)); } catch { return null; }
+ if (!row) return null;
+ const published = publishedOf(jsonOf(row.published));
+ const snapshot = jsonOf(row.snapshot);
+ const person = snapshot && snapshot.person && typeof snapshot.person === 'object'
+ ? snapshot.person
+ : { username: row.username, display_name: row.display_name || row.username, ...(row.avatar_url ? { avatar_url: row.avatar_url } : {}) };
+ const base = snapshot && typeof snapshot === 'object' ? snapshot : {};
+ const sections = sectionsFor(base.sections || {}, published, false);
+ return {
+ generated_at: typeof base.generated_at === 'string' ? base.generated_at : iso(timeOf(row.snapshot_at) || timeOf(row.updated_at) || clock()),
+ person,
+ window: base.window && typeof base.window === 'object' ? base.window : { days: 30, from: dayOf(clock() - 29 * DAY), to: dayOf(clock()) },
+ published,
+ sections,
+ };
+ }
+
+ /** The people a sitemap can list: only those who published at least one section. */
+ async function listPublished({ limit = 500 } = {}) {
+ if (!db) return [];
+ const cap = Math.max(1, Math.min(2000, Number(limit) || 500));
+ let rows;
+ try { rows = await db.prepare('SELECT username, published, snapshot, snapshot_at, updated_at FROM report_me_stats ORDER BY updated_at DESC LIMIT ?').all(cap); } catch { return []; }
+ return rows.filter((r) => anyPublished(publishedOf(jsonOf(r.published)))).map((r) => {
+ const snapshot = jsonOf(r.snapshot);
+ return {
+ username: r.username,
+ generated_at: snapshot && typeof snapshot.generated_at === 'string' ? snapshot.generated_at : iso(timeOf(r.snapshot_at) || timeOf(r.updated_at) || clock()),
+ updated_at: iso(timeOf(r.updated_at) || clock()),
+ };
+ });
+ }
+
+ return { compute, setPublished, publicFor, listPublished, SECTIONS, publishedOf, anyPublished };
+}
+
+/**
+ * The sections a document may carry: all of them for the owner, only the published ones for a public reader. A
+ * published section whose source did not answer stays absent (there is nothing in `sections` to copy).
+ */
+function sectionsFor(sections, published, owner) {
+ const out = {};
+ for (const key of SECTIONS) {
+ if (!owner && published[key] !== true) continue;
+ const value = sections && sections[key];
+ if (value && typeof value === 'object') out[key] = value;
+ }
+ return out;
+}
+
+module.exports = { createMeStats, fromHistory, publishedOf, personOf, sectionsFor, SECTIONS, HEAT_WEEKS, TOP_SITES };
@@ -101,8 +101,17 @@ const NAV = [
@@ -101,8 +101,17 @@ const NAV = [
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
{ label: 'API', href: '/developers', icon: 'fa-code', more: true },
];
];
-function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [], agent = false, inlineStyles = [] }) {
+function layout({ title, description, canonical = null, path = null, robots = 'index, follow', body, active = '/', jsonLd = [], agent = false, inlineStyles = [], og = null }) {
canonical = canonical || (path ? canon(path) : undefined);
canonical = canonical || (path ? canon(path) : undefined);
+ const ogTags = og ? [
+ `<meta property="og:type" content="${esc(og.type || 'website')}">`,
+ `<meta property="og:site_name" content="${esc(NAME)}">`,
+ `<meta property="og:title" content="${esc(og.title || title || NAME)}">`,
+ `<meta property="og:description" content="${esc(og.description || description || '')}">`,
+ og.url ? `<meta property="og:url" content="${esc(og.url)}">` : '',
+ og.image ? `<meta property="og:image" content="${esc(og.image)}">` : '',
+ '<meta name="twitter:card" content="summary">',
+ ].filter(Boolean).join('\n') : '';
const navbar = {
const navbar = {
service: 'report',
service: 'report',
apiBase: NETWORK,
apiBase: NETWORK,
@@ -143,6 +152,7 @@ ${path && String(path).startsWith('/ai') ? `<link rel="alternate" type="applicat
@@ -143,6 +152,7 @@ ${path && String(path).startsWith('/ai') ? `<link rel="alternate" type="applicat
<script src="${esc(ovServe.url('kit-data-web.js'))}" defer></script>
<script src="${esc(ovServe.url('kit-data-web.js'))}" defer></script>
<script src="${esc(ovServe.url('motion.js'))}" defer></script>
<script src="${esc(ovServe.url('motion.js'))}" defer></script>
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
+${ogTags}
<meta name="ov-boost" content="report@${esc(RELEASE)}">`,
<meta name="ov-boost" content="report@${esc(RELEASE)}">`,
body: `<a class="skip" href="#main">Skip to content</a>
body: `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
@@ -620,7 +630,7 @@ ${(sp.topics || []).length ? `<ul class="rp-chips">${sp.topics.slice(0, 12).map(
@@ -620,7 +630,7 @@ ${(sp.topics || []).length ? `<ul class="rp-chips">${sp.topics.slice(0, 12).map(
${(sp.episodes || []).length ? `<p class="rp-meta">${plural(sp.episodes.length, 'stream', 'streams')} written up · <a href="/rundown/s/${encodeURIComponent(username)}">all of it</a></p>` : ''}
${(sp.episodes || []).length ? `<p class="rp-meta">${plural(sp.episodes.length, 'stream', 'streams')} written up · <a href="/rundown/s/${encodeURIComponent(username)}">all of it</a></p>` : ''}
</section>` : '';
</section>` : '';
const body = `<header class="rp-page-h rp-person">${kit.mark({ name, src: user.avatar_url || '', size: 64 })}<div><p class="rp-kicker">Your numbers</p><h1>${esc(name)}</h1>
const body = `<header class="rp-page-h rp-person">${kit.mark({ name, src: user.avatar_url || '', size: 64 })}<div><p class="rp-kicker">Your numbers</p><h1>${esc(name)}</h1>
-<p class="rp-lede rp-you-say">${esc(youSentence({ name, days, stats, speaker }))}</p><nav class="rp-tabs" aria-label="Time range">${tabs}</nav></div></header>
+<p class="rp-lede rp-you-say">${esc(youSentence({ name, days, stats, speaker }))}</p><nav class="rp-tabs" aria-label="Time range">${tabs}</nav><p class="rp-meta"><a href="/me">All your stats: your places, activity and what you made</a></p></div></header>
${streams}${money}${youAi(ai, days)}${youReports(reports, defaultAudience)}${rundown}
${streams}${money}${youAi(ai, days)}${youReports(reports, defaultAudience)}${rundown}
<section aria-labelledby="rp-you-next" class="rp-you-next"><h2 id="rp-you-next">Coming here</h2><p class="rp-fine">Your agents' work and what your computers ran. Only you see this page.</p></section>`;
<section aria-labelledby="rp-you-next" class="rp-you-next"><h2 id="rp-you-next">Coming here</h2><p class="rp-fine">Your agents' work and what your computers ran. Only you see this page.</p></section>`;
return layout({ title: 'Your numbers', description: 'Your own numbers across OpenVibe: streams, money and The Rundown, in one place.', body, active: '/you', robots: 'noindex, nofollow' });
return layout({ title: 'Your numbers', description: 'Your own numbers across OpenVibe: streams, money and The Rundown, in one place.', body, active: '/you', robots: 'noindex, nofollow' });
@@ -1205,8 +1215,206 @@ function staffOnly({ signedIn = false } = {}) {
@@ -1205,8 +1215,206 @@ function staffOnly({ signedIn = false } = {}) {
return layout({ title: 'Staff only', description: 'Staff only.', robots: 'noindex, nofollow', body: `<header class="rp-page-h"><p class="rp-kicker">OpenVibe.Report</p><h1>Staff only</h1><p class="rp-lede">${esc(text)}</p>${action}</header>` });
return layout({ title: 'Staff only', description: 'Staff only.', robots: 'noindex, nofollow', body: `<header class="rp-page-h"><p class="rp-kicker">OpenVibe.Report</p><h1>Staff only</h1><p class="rp-lede">${esc(text)}</p>${action}</header>` });
}
}
+// ── A person's stats (the owner, 2026-11-09): private /me, public /u/<name> ─────────────────────────────
+//
+// Every number is a source's: Network's cross-site history (visits, not minutes), Report's analytics (streams and
+// hours), Report's AI meter, Billing's settled Vibes and the person's session reports. A section a source did not
+// answer for is absent, never a zero. The charts are server-drawn SVG with the numbers in their aria-label, and the
+// tables are the same numbers as text.
+const STAT_SECTIONS = [
+ ['sites', 'Sites you used', 'The OpenVibe sites you opened, from Network’s history. A page opened again within ten minutes is one visit.'],
+ ['activity', 'Your activity', 'The days you were around, week by week, and the streak that ends today.'],
+ ['made', 'Things you made', 'What Report can count so far: your streams and hours, and the agent sessions you ran.'],
+ ['ai', 'Your AI', 'Your own calls on the AI meter: what they cost and how many there were.'],
+ ['money', 'Vibes', 'What Billing says reached you. Report stores no money and derives no amount.'],
+];
+const STAT_LABEL = Object.fromEntries(STAT_SECTIONS.map(([k, label]) => [k, label]));
+// The public page speaks about the person, never to the reader: the same sections, in the third person.
+const STAT_PUBLIC_NOTE = {
+ sites: 'The OpenVibe sites they opened, from Network’s history. A page opened again within ten minutes is one visit.',
+ activity: 'The days they were around, week by week, and the streak that ends today.',
+ made: 'What Report can count so far: their streams and hours, and the agent sessions they ran.',
+ ai: 'Their own calls on the AI meter: what they cost and how many there were.',
+ money: 'What Billing says reached them. Report stores no money and derives no amount.',
+};
+
+function statSites(sites) {
+ if (!sites || !Array.isArray(sites.month) || !sites.month.length) {
+ return kit.empty({ icon: 'compass', title: 'No places yet', text: 'When you use OpenVibe signed in, the sites you open show up here. Nothing is recorded for signed-out visits.' });
+ }
+ const week = new Map((Array.isArray(sites.week) ? sites.week : []).map((s) => [s.service, s.visits]));
+ const top = Math.max(1, ...sites.month.map((s) => Number(s.visits) || 0));
+ // Designed 11-09: a bar per site reads at a glance (it was a table of two numbers per site).
+ const rows = sites.month.map((s, i) => {
+ const n = Number(s.visits) || 0, w = week.get(s.service) || 0;
+ return `<li style="--p:${Math.max(3, Math.round(n / top * 100))}%;--i:${i}"><a href="${esc(s.url)}">${esc(s.label)}</a>`
+ + `<span class="rp-sbar" aria-hidden="true"><i></i></span><b>${fmt(n)}</b><small>${w ? `${fmt(w)} this week` : 'none this week'}</small></li>`;
+ }).join('');
+ return `<ol class="rp-sites" aria-label="Visits by site in this window, with this week's">${rows}</ol>
+<p class="rp-fine">Counted from Network’s cross-site history: a visit is a page, not a length of time. Times are UTC.</p>`;
+}
+
+/** Twelve weeks as a grid of days (a column a week, Monday on top), shaded by visits; the numbers stay in a table. */
+function heatmap(weeks) {
+ const all = weeks.flatMap((w) => (w.days || []).map((n) => Number(n) || 0));
+ const max = Math.max(1, ...all);
+ const level = (n) => (n <= 0 ? 0 : Math.min(4, Math.ceil(n / max * 4)));
+ const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
+ const cols = weeks.map((w) => `<span class="rp-heat-col">${DAYS.map((d, j) => {
+ const n = Number((w.days || [])[j]) || 0;
+ return `<i class="l${level(n)}" title="${esc(`${d}, week of ${day(w.week_start)}: ${n} ${n === 1 ? 'visit' : 'visits'}`)}"></i>`;
+ }).join('')}</span>`).join('');
+ return `<div class="rp-heat" role="img" aria-label="${esc(`Visits a day for ${weeks.length} weeks; the table below has every number`)}">
+<span class="rp-heat-days" aria-hidden="true"><i>Mon</i><i></i><i>Wed</i><i></i><i>Fri</i><i></i><i>Sun</i></span><span class="rp-heat-grid">${cols}</span></div>
+<p class="rp-heat-key" aria-hidden="true"><span>${esc(day(weeks[0].week_start))}</span><span class="rp-heat-scale">Fewer <i class="l0"></i><i class="l1"></i><i class="l2"></i><i class="l3"></i><i class="l4"></i> More</span><span>${esc(day(weeks[weeks.length - 1].week_start))}</span></p>`;
+}
+
+function statActivity(activity) {
+ if (!activity || !Array.isArray(activity.weeks) || !activity.weeks.length) return '';
+ if (!activity.active_days && !activity.streak_days) {
+ return kit.empty({ icon: 'calendar', title: 'No activity yet', text: 'Your days fill in as you use OpenVibe signed in. Nothing is recorded for signed-out visits.' });
+ }
+ const weeks = activity.weeks.map((w) => ({ week_start: w.week_start, week: day(w.week_start), total: (w.days || []).reduce((a, b) => a + (Number(b) || 0), 0), days: w.days || [] }));
+ const chart = bars(weeks.map((w) => ({ day: w.week_start, n: w.total })), 'Visits a week', { h: 90 });
+ const rows = activity.weeks.map((w, i) => ({
+ week: day(w.week_start),
+ mon: (w.days && w.days[0]) || 0, tue: (w.days && w.days[1]) || 0, wed: (w.days && w.days[2]) || 0, thu: (w.days && w.days[3]) || 0,
+ fri: (w.days && w.days[4]) || 0, sat: (w.days && w.days[5]) || 0, sun: (w.days && w.days[6]) || 0,
+ }));
+ return `<div class="rp-tiles rp-tiles-sm">${tile('Active days', exact(activity.active_days))}${tile('Streak', exact(activity.streak_days), Number(activity.streak_days) === 1 ? 'day in a row' : 'days in a row')}</div>
+${heatmap(activity.weeks)}
+${chart}
+<details class="rp-fold"><summary>Every day as numbers <span>${activity.weeks.length} weeks</span></summary>
+${kit.table({
+ caption: 'Visits by day, last twelve weeks (UTC)',
+ stack: false,
+ columns: [
+ { key: 'week', label: 'Week of', primary: true },
+ { key: 'mon', label: 'Mon', num: true }, { key: 'tue', label: 'Tue', num: true }, { key: 'wed', label: 'Wed', num: true },
+ { key: 'thu', label: 'Thu', num: true }, { key: 'fri', label: 'Fri', num: true }, { key: 'sat', label: 'Sat', num: true }, { key: 'sun', label: 'Sun', num: true },
+ ],
+ rows,
+ })}</details>`;
+}
+
+function statMade(made, { publicView = false } = {}) {
+ const items = made && Array.isArray(made.items) ? made.items : [];
+ if (!items.length) return kit.empty({ icon: 'sparkles', title: 'Nothing counted yet', text: 'Streams, hours and your agent reports appear here when Report has them for this window.' });
+ return `<div class="rp-tiles rp-tiles-sm">${items.map((it) => tile(it.label, `${fmt(it.count)}<small> ${esc(it.unit || '')}</small>`, (it.href && (!publicView || it.key === 'streams') ? `<a href="${esc(it.href)}">See them</a>` : ''))).join('')}</div>`;
+}
+
+function statAi(ai, { publicView = false } = {}) {
+ if (!ai || !(Number(ai.calls) > 0)) return '';
+ const daily = (ai.daily || []).map((d) => ({ day: d.day, n: d.calls }));
+ return `<div class="rp-tiles rp-tiles-sm">${tile('Calls', exact(ai.calls))}${tile('Tokens', fmt(ai.tokens))}${tile('Cost', usd(ai.cost_usd))}</div>
+${daily.length > 1 ? bars(daily, 'AI calls a day', { h: 100 }) : ''}
+${publicView ? '' : '<p class="rp-meta"><a href="/you#rp-you-ai">The whole AI meter</a> · <a href="/api/v1/me/ai/usage">As JSON</a></p>'}`;
+}
+
+function statMoney(money) {
+ if (!money || !(Number(money.transactions) > 0)) return '';
+ return `<div class="rp-tiles rp-tiles-sm">${tile('Vibes earned', exact(money.settled_bits))}${tile('Payments', exact(money.transactions))}</div>
+<p class="rp-fine">These are Billing’s settled amounts. Report never computes a price, a fee or a balance: <a href="https://billing.openvibe.network/">every receipt is on Billing</a>.</p>`;
+}
+
+/** One publish row: a checkbox and its hidden false, so an unchecked box is an explicit "no". */
+function publishRow(key, checked, note) {
+ return `<li class="rp-publish-row"><label><input type="hidden" name="${esc(key)}" value="false"><input class="rp-switch" type="checkbox" role="switch" name="${esc(key)}" value="true"${checked ? ' checked' : ''}> <b>${esc(STAT_LABEL[key] || key)}</b></label> <span class="rp-fine">· ${esc(note)}</span></li>`;
+}
+
+function publishPanel({ doc, username }) {
+ const p = doc.published || {};
+ const any = Object.values(p).some(Boolean);
+ const publicUrl = `${BASE}/u/${encodeURIComponent(username)}`;
+ return `<section class="rp-share" id="rp-share" aria-labelledby="rp-pub-h"><h2 id="rp-pub-h">Share your stats</h2>
+<p class="rp-lede">Private by default: only you see this page. Turn a section on to publish it at <a href="/u/${encodeURIComponent(username)}">/u/${esc(username)}</a>. Each section is a separate choice; a published section shows the numbers in this snapshot.</p>
+<form class="rp-publish" method="post" action="/api/v1/me/stats/published">
+<fieldset><legend class="rp-sr">Public sections</legend><ul>${STAT_SECTIONS.map(([key]) => publishRow(key, p[key], p[key] ? 'Public now' : 'Only you')).join('')}</ul>
+${kit.button({ label: 'Save public sections', icon: 'check' })}
+</fieldset></form>
+${any ? `<div class="rp-share-card"><p class="rp-kicker">Share card</p><h3>${esc(doc.person.display_name || username)}’s OpenVibe stats</h3><p class="rp-fine">Anyone with this link sees only the sections you made public.</p><p><a href="/u/${encodeURIComponent(username)}">${esc(publicUrl)}</a></p></div>` : `<p class="rp-fine">Nothing is public yet. Your public link will be <code>/u/${esc(username)}</code>.</p>`}
+</section>`;
+}
+
+function me({ user, doc, days = 30 } = {}) {
+ const name = String((user && (user.display_name || user.username)) || doc.person.display_name || 'you');
+ const username = String(doc.person.username || (user && user.username) || '');
+ const s = doc.sections || {};
+ const tabs = [7, 30, 90].map((d) => `<a href="/me?days=${d}"${Number(days) === d ? ' aria-current="page"' : ''}>${d} days</a>`).join('');
+ const body = `<header class="rp-page-h">${kit.mark({ name, src: (user && user.avatar_url) || doc.person.avatar_url || '', size: 64 })}<div><p class="rp-kicker">Your stats</p><h1>${esc(name)}</h1>
+<p class="rp-lede">Everything OpenVibe counts for you, in one place. Private by default; you choose what is public.</p><nav class="rp-tabs" aria-label="Time range">${tabs}</nav></div></header>
+${doc.note ? kit.notice({ kind: 'info', text: doc.note }) : ''}
+${(() => { const n = Object.values(doc.published || {}).filter(Boolean).length; return `<p class="rp-privacy${n ? ' is-public' : ''}"><span>${n ? `${n} ${n === 1 ? 'section is' : 'sections are'} public` : 'Private: only you see this page'}</span> <a href="#rp-share">${n ? 'Change' : 'Share some of it'}</a></p>`; })()}
+${s.sites ? kit.section({ title: 'Sites you used', count: (s.sites.month || []).length, note: STAT_SECTIONS[0][2], body: statSites(s.sites), id: 'rp-me-sites' }) : ''}
+${s.activity ? kit.section({ title: 'Your activity', note: STAT_SECTIONS[1][2], body: statActivity(s.activity), id: 'rp-me-activity' }) : ''}
+${s.made ? kit.section({ title: 'Things you made', note: STAT_SECTIONS[2][2], body: statMade(s.made), id: 'rp-me-made' }) : ''}
+${s.ai ? kit.section({ title: 'Your AI', note: STAT_SECTIONS[3][2], body: statAi(s.ai), id: 'rp-me-ai' }) : ''}
+${s.money ? kit.section({ title: 'Vibes', note: STAT_SECTIONS[4][2], body: statMoney(s.money), id: 'rp-me-money' }) : ''}
+${publishPanel({ doc, username })}
+<p class="rp-fine">Counted ${esc(dayTime(doc.generated_at))}. <a href="/you">Your streams, money, AI and The Rundown</a> · <a href="/api/v1/me/stats?days=${esc(days)}">Your numbers as JSON</a>.</p>`;
+ return layout({ title: 'Your stats', description: 'Your own numbers across OpenVibe: the sites you used, your activity, what you made, your AI and your Vibes.', body, active: '/me', robots: 'noindex, nofollow' });
+}
+
+function meSignedOut() {
+ const body = `<header class="rp-page-h"><p class="rp-kicker">Your stats</p><h1>Your OpenVibe, in numbers</h1>
+<p class="rp-lede">The sites you use, the days you were around, what you made, your AI and the Vibes that reached you. Private by default: you choose which sections, if any, are public.</p>
+<p>${kit.button({ href: `/auth/login?next=${encodeURIComponent('/me')}`, label: 'Sign in to see yours', icon: 'forward' })}</p>
+<p class="rp-fine">A public stats page looks like <a href="/stats">the rest of Report</a>: server-rendered numbers, no account needed to read what someone chose to publish.</p></header>`;
+ return layout({ title: 'Your stats', description: 'Sign in to see your own numbers across OpenVibe.', body, active: '/me', robots: 'noindex, nofollow' });
+}
+
+function meError({ user } = {}) {
+ const name = String((user && (user.display_name || user.username)) || 'there');
+ return layout({ title: 'Your stats', robots: 'noindex, nofollow', body: `<header class="rp-page-h"><p class="rp-kicker">Your stats</p><h1>${esc(name)}, your numbers did not load</h1><p class="rp-lede">One of the sources did not answer. Nothing is shown as a zero. Try again in a moment; <a href="/you">your streams, AI and Rundown</a> are still here.</p></header>` });
+}
+
+function publicStats({ doc } = {}) {
+ const person = doc.person || {};
+ const username = String(person.username || '');
+ const name = String(person.display_name || username);
+ const s = doc.sections || {};
+ const any = Object.values(doc.published || {}).some(Boolean);
+ const body = `<header class="rp-page-h">${kit.mark({ name, src: person.avatar_url || '', size: 64 })}<div><p class="rp-kicker">Public stats</p><h1>${esc(name)}’s OpenVibe</h1>
+<p class="rp-lede">The numbers ${esc(name)} chose to publish. Private by default: nothing else is here.</p></div></header>
+${s.sites ? kit.section({ title: 'Sites used', count: (s.sites.month || []).length, note: STAT_PUBLIC_NOTE.sites, body: statSites(s.sites), id: 'rp-u-sites' }) : ''}
+${s.activity ? kit.section({ title: 'Activity', note: STAT_PUBLIC_NOTE.activity, body: statActivity(s.activity), id: 'rp-u-activity' }) : ''}
+${s.made ? kit.section({ title: 'Things made', note: STAT_PUBLIC_NOTE.made, body: statMade(s.made, { publicView: true }), id: 'rp-u-made' }) : ''}
+${s.ai ? kit.section({ title: 'AI', note: STAT_PUBLIC_NOTE.ai, body: statAi(s.ai, { publicView: true }), id: 'rp-u-ai' }) : ''}
+${s.money ? kit.section({ title: 'Vibes', note: STAT_PUBLIC_NOTE.money, body: statMoney(s.money), id: 'rp-u-money' }) : ''}
+<p class="rp-fine">Published ${esc(dayTime(doc.generated_at))} · ${any ? 'only the sections above' : 'no sections'} · <a href="/stats">the network’s open numbers</a>.</p>`;
+ return layout({
+ title: `${name}: OpenVibe stats`,
+ description: `${name}’s OpenVibe numbers: the sites they used, their activity and what they made. Only the sections they made public.`,
+ path: `/u/${encodeURIComponent(username)}`,
+ robots: 'index, follow',
+ jsonLd: [{
+ '@context': 'https://schema.org',
+ '@type': 'Person',
+ name,
+ alternateName: username,
+ url: canon(`/u/${encodeURIComponent(username)}`),
+ ...(person.avatar_url ? { image: person.avatar_url } : {}),
+ description: `${name}’s public OpenVibe stats, published by them on OpenVibe.Report.`,
+ }],
+ og: { type: 'profile', title: `${name}: OpenVibe stats`, description: `${name}’s public OpenVibe numbers. Private by default.`, url: canon(`/u/${encodeURIComponent(username)}`), image: person.avatar_url || undefined },
+ body,
+ active: '/me',
+ });
+}
+
+function publicStatsPrivate({ doc } = {}) {
+ const person = (doc && doc.person) || {};
+ const name = String(person.display_name || person.username || 'This person');
+ return layout({
+ title: `${name}: private stats`,
+ robots: 'noindex, nofollow',
+ body: `<header class="rp-page-h"><p class="rp-kicker">Private</p><h1>${esc(name)} keeps their numbers private</h1><p class="rp-lede">Nothing has been published from this page. On OpenVibe.Report, a person’s stats are private until they turn a section on.</p><p class="rp-fine"><a href="/stats">The network’s open numbers</a> · <a href="/">OpenVibe.Report</a></p></header>`,
+ active: '/me',
+ });
+}
+
function notFound() {
function notFound() {
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });
return layout({ title: 'Not found', robots: 'noindex, follow', body: `<header class="rp-page-h"><h1>Nothing here</h1><p class="rp-lede">That page does not exist, or its source has nothing for it. <a href="/">Back to Report</a>.</p></header>` });
}
}
-module.exports = { home, stats, rundown, sessionReport, sharingChip, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiBenchmark, aiModel, aiCompare, aiMethod, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };
+module.exports = { home, stats, rundown, sessionReport, sharingChip, topic, episode, speaker, recap, channel, you, youSignedOut, me, meSignedOut, meError, publicStats, publicStatsPrivate, developers, aiIndex, aiBoard, aiBenchmark, aiModel, aiCompare, aiMethod, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };
@@ -90,6 +90,19 @@ function defaults() {
@@ -90,6 +90,19 @@ function defaults() {
channel: {
channel: {
recaps: [{ stream_id: 7, title: 'Seattle Today', headline: 'A tiny but mighty vibe', grade: 'A', duration_seconds: 2017, ended_at: '2026-10-27 10:33:00' }],
recaps: [{ stream_id: 7, title: 'Seattle Today', headline: 'A tiny but mighty vibe', grade: 'A', duration_seconds: 2017, ended_at: '2026-10-27 10:33:00' }],
},
},
+ // The person's own cross-site history (GET /api/history), used by /me and GET /api/v1/me/stats. A test that
+ // wants "Network did not answer" overrides it with null.
+ history: {
+ items: [
+ { service: 'live', service_label: 'Live', title: 'A stream', url: 'https://openvibe.live/watch/7', updated_at: '2026-11-09 10:00:00', hits: 1 },
+ { service: 'live', service_label: 'Live', title: 'Another stream', url: 'https://openvibe.live/watch/8', updated_at: '2026-11-08 10:00:00', hits: 1 },
+ { service: 'community', service_label: 'Community', title: 'A paste', url: 'https://openvibe.community/paste/1', updated_at: '2026-11-07 10:00:00', hits: 1 },
+ { service: 'chat', service_label: 'Chat', title: 'A room', url: 'https://openvibe.chat/room', updated_at: '2026-10-20 10:00:00', hits: 1 },
+ ],
+ total: 4,
+ paused: false,
+ services: [{ service: 'live', label: 'Live', count: 2 }, { service: 'community', label: 'Community', count: 1 }, { service: 'chat', label: 'Chat', count: 1 }],
+ },
};
};
}
}
@@ -118,6 +131,9 @@ function stubFetch(data = {}, { health = { network: true, live: true } } = {}) {
@@ -118,6 +131,9 @@ function stubFetch(data = {}, { health = { network: true, live: true } } = {}) {
if (p.endsWith('/api/v1/stats')) {
if (p.endsWith('/api/v1/stats')) {
return answers.stats == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.stats);
return answers.stats == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.stats);
}
}
+ if (p.endsWith('/api/history')) {
+ return answers.history == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.history);
+ }
if (p.endsWith('/api/rundown/')) {
if (p.endsWith('/api/rundown/')) {
return answers.rundown == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.rundown);
return answers.rundown == null ? jsonResponse({ error: 'unavailable' }, 404) : jsonResponse(answers.rundown);
}
}
@@ -178,6 +194,7 @@ async function boot(opts = {}) {
@@ -178,6 +194,7 @@ async function boot(opts = {}) {
sso: opts.sso,
sso: opts.sso,
shadow: opts.shadow,
shadow: opts.shadow,
...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),
...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),
+ ...(opts.sessionReports ? { sessionReports: opts.sessionReports } : {}),
...(opts.bench ? { bench: opts.bench } : {}),
...(opts.bench ? { bench: opts.bench } : {}),
...(opts.aiCatalogue ? { aiCatalogue: opts.aiCatalogue } : {}),
...(opts.aiCatalogue ? { aiCatalogue: opts.aiCatalogue } : {}),
...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),
...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),
new file mode 100644
new file mode 100644
@@ -0,0 +1,238 @@
@@ -0,0 +1,238 @@
+'use strict';
+/**
+ * A person's stats (the owner, 2026-11-09): /me, GET /api/v1/me/stats, the per-section publish switches, the public
+ * /u/<name> page and GET /api/v1/u/:name/stats. The sources are stubbed: Network's cross-site history, Report's
+ * analytics, the AI meter and session reports; the store is a real migrated PGlite, so the snapshot and the switches
+ * are tested where they live.
+ *
+ * The rules under test: the subject is the session's and never a query value; a source that does not answer leaves its
+ * section out (never a zero); a public reader sees only the published sections; a private person's API is a 404 and
+ * their page is noindex; the public page carries real words, a heading, a canonical and Person JSON-LD.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { createSsoClient } = require('openvibe-sdk/sso');
+const { boot, check, done } = require('./helpers/boot');
+
+let contracts = null;
+try {
+ contracts = require('openvibe-contracts');
+ contracts.resolve('report.me-stats@1');
+} catch { contracts = null; }
+
+const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+function token(claims) {
+ const head = b64({ alg: 'RS256', typ: 'JWT', kid: 'test' });
+ const now = Math.floor(Date.now() / 1000);
+ const body = b64({ iss: 'https://openvibe.network', aud: ['openvibe.network'], iat: now, exp: now + 600, ...claims });
+ const sig = crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), privateKey).toString('base64url');
+ return `${head}.${body}.${sig}`;
+}
+const goosely = token({ sub: '7', subject_id: 'usr_01JTESTGOOSELY0000000000000', username: 'goosely', display_name: 'Goosely' });
+const quiet = token({ sub: '8', subject_id: 'usr_01JTESTQUIET00000000000000', username: 'quiet', display_name: 'Quiet' });
+
+const NOW = Date.UTC(2026, 10, 9, 12, 0, 0); // 2026-11-09, a Monday
+const viewed = [];
+const analytics = {
+ source: 'network',
+ async channel(name) {
+ if (name === 'quiet') return null;
+ return {
+ channel: name, days: 30,
+ totals: { streams: 3, stream_seconds: 9000, peak_viewers: 41, avg_viewers: 12, unique_chatters: 9, messages: 230, watch_minutes: 2100 },
+ daily: [],
+ revenue: { source: 'billing', currency: 'vibes-bits', days: 30, transactions: 2, settled_bits: 1200, refunded_bits: 0 },
+ };
+ },
+ async stats() { return null; },
+ metrics: async () => null,
+ reportStats: () => null,
+};
+const aiUsage = {
+ async forPerson({ subject, from, group }) {
+ viewed.push({ subject, from, group });
+ if (subject.includes('QUIET')) return null;
+ return {
+ group, source: 'raw',
+ rows: [{ key: '2026-11-08', calls: 12, cost_usd: 0.2 }, { key: '2026-11-09', calls: 9, cost_usd: 0.22 }],
+ totals: { calls: 21, input_tokens: 30000, output_tokens: 18000, cached_tokens: 9000, cost_usd: 0.42, fee_usd: 0.02, charged_usd: 0.44, saved_usd: 0.01 },
+ by_paid_by: { openvibe_key: { calls: 21, charged_usd: 0.44, fee_usd: 0.02 } },
+ insights: ['Your calls cost $0.44, including $0.02 in fees.'],
+ };
+ },
+};
+const sessionReports = {
+ async listForOwner(owner) { return [{ id: 'rps_01JTESTREPORT0000000000000', owner, created_at: '2026-11-08T10:00:00Z', task: { title: 'A session' } }]; },
+ async listPublic() { return []; },
+ async defaultAudience() { return 'private'; },
+};
+
+(async () => {
+ const sso = createSsoClient({ site: 'report', baseUrl: 'https://openvibe.report', clientId: 'report', clientSecret: 'x', networkUrl: 'https://openvibe.network', publicKey: publicKey.export({ type: 'spki', format: 'pem' }), log: { warn() {}, info() {}, log() {}, error() {} } });
+ const t = await boot({
+ db: true,
+ now: () => NOW,
+ sso,
+ analytics,
+ aiUsage,
+ sessionReports,
+ env: { OV_NETWORK_PUBLIC_KEY: publicKey.export({ type: 'spki', format: 'pem' }) },
+ });
+ const auth = { cookie: `ov_token=${goosely}` };
+ const publicApi = '/api/v1/u/goosely/stats';
+ try {
+ await check('signed out: the API is 401 and /me asks for a sign-in', async () => {
+ const api = await t.get('/api/v1/me/stats');
+ assert.strictEqual(api.status, 401);
+ assert.strictEqual(api.json().code, 'auth.sign_in_required');
+ const page = await t.get('/me');
+ assert.strictEqual(page.status, 200);
+ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
+ assert.ok(page.text.includes('Sign in to see yours'), 'the sign-in call to action');
+ assert.match(page.text, /<meta name="robots" content="noindex, nofollow">/);
+ });
+
+ await check('signed in: one document with every section the sources answered for', async () => {
+ const r = await t.get('/api/v1/me/stats?days=30', { headers: auth });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ const body = r.json();
+ assert.strictEqual(body.person.username, 'goosely');
+ assert.deepStrictEqual(body.window, { days: 30, from: '2026-10-11', to: '2026-11-09' });
+ assert.deepStrictEqual(body.published, { sites: false, activity: false, made: false, ai: false, money: false });
+ assert.strictEqual(body.sections.sites.month[0].service, 'live');
+ assert.strictEqual(body.sections.sites.month[0].visits, 2);
+ assert.strictEqual(body.sections.sites.week[0].visits, 2);
+ assert.ok(body.sections.sites.top.length <= 8);
+ assert.strictEqual(body.sections.activity.weeks.length, 12);
+ assert.strictEqual(body.sections.activity.weeks[11].days.length, 7);
+ assert.strictEqual(body.sections.activity.streak_days, 3, 'the 9th, 8th and 7th have history');
+ assert.strictEqual(body.sections.ai.calls, 21);
+ assert.strictEqual(body.sections.ai.tokens, 48000);
+ assert.strictEqual(body.sections.money.settled_bits, 1200);
+ assert.strictEqual(body.sections.money.transactions, 2);
+ assert.ok(body.sections.made.items.some((i) => i.key === 'streams' && i.count === 3));
+ assert.ok(body.sections.made.items.some((i) => i.key === 'agent_reports' && i.count === 1));
+ assert.strictEqual(viewed[viewed.length - 1].subject, 'usr_01JTESTGOOSELY0000000000000', 'the session subject, not a query');
+ if (contracts) {
+ const v = contracts.validate('report.me-stats@1', body);
+ assert.ok(v.valid, `report.me-stats@1: ${JSON.stringify(v.errors)}`);
+ }
+ });
+
+ await check('/me: real words, a heading, each section, the switches and the numbers as text', async () => {
+ const r = await t.get('/me?days=30', { headers: auth });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);
+ for (const words of ['Share your stats', 'Private: only you see this page', 'Sites you used', 'Your activity', 'Things you made', 'Your AI', 'Vibes', 'this week', 'Visits by day, last twelve weeks', 'Every day as numbers', 'Live', 'Community', 'Chat']) {
+ assert.ok(r.text.includes(words), `the page says ${words}`);
+ }
+ assert.ok(r.text.includes('Only you'), 'each switch says its private state');
+ assert.ok(r.text.includes('action="/api/v1/me/stats/published"'), 'the publish form');
+ assert.ok(r.text.includes('aria-label="Visits a week'), 'the chart carries its numbers for screen readers');
+ assert.ok(r.text.includes('href="/api/v1/me/stats?days=30"'), 'the JSON link');
+ });
+
+ await check('a source that does not answer leaves its section out, never a zero', async () => {
+ const direct = await t.ctx.meStats.compute({ user: { subject_id: 'usr_01JTESTQUIET00000000000000', username: 'quiet', display_name: 'Quiet' }, token: null, days: 7 });
+ assert.ok(!direct.sections.sites && !direct.sections.activity, 'no history: no sites and no activity');
+ assert.ok(!direct.sections.ai, 'no AI meter: no AI section');
+ assert.ok(direct.note && /Network|analytics/.test(direct.note), 'the note says what did not answer');
+ });
+
+ await check('the switches: a JSON PUT sets the five public choices', async () => {
+ const r = await t.get('/api/v1/me/stats?days=30', { headers: auth });
+ assert.strictEqual(r.status, 200);
+ const all = r.json();
+ assert.ok(all.sections.ai, 'the owner sees AI');
+ const put = await t.get('/api/v1/me/stats/published', {
+ method: 'PUT',
+ headers: { ...auth, 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
+ json: { published: { sites: true, activity: true, made: false, ai: false, money: false } },
+ });
+ assert.strictEqual(put.status, 200, put.text);
+ const body = put.json();
+ assert.deepStrictEqual(body.published, { sites: true, activity: true, made: false, ai: false, money: false });
+ assert.ok(body.sections.sites && body.sections.activity);
+ assert.ok(body.sections.ai && body.sections.money && body.sections.made, 'the owner still sees the sections they kept private');
+ });
+
+ await check('the public API and page carry only the published sections', async () => {
+ const api = await t.get(publicApi);
+ assert.strictEqual(api.status, 200);
+ const body = api.json();
+ assert.deepStrictEqual(body.published, { sites: true, activity: true, made: false, ai: false, money: false });
+ assert.ok(body.sections.sites && body.sections.activity);
+ assert.ok(!body.sections.ai && !body.sections.money && !body.sections.made, 'a stranger never sees an unpublished section');
+ if (contracts) assert.ok(contracts.validate('report.me-stats@1', body).valid);
+ const page = await t.get('/u/goosely');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<meta name="robots" content="index, follow">/);
+ assert.ok(page.text.includes('Goosely’s OpenVibe') || page.text.includes('Goosely\'s OpenVibe'));
+ assert.ok(page.text.includes('"@type":"Person"'), 'Person JSON-LD');
+ assert.ok(page.text.includes('canonical" href="https://openvibe.report/u/goosely"'));
+ assert.ok(page.text.includes('Sites used') && page.text.includes('Activity'));
+ assert.ok(!page.text.includes('Your AI') && !page.text.includes('Things made'), 'unpublished sections are absent');
+ assert.ok(page.text.includes('Published'), 'the snapshot is dated');
+ });
+
+ await check('a private person: the API is a 404 and the page is a noindex notice', async () => {
+ const put = await t.get('/api/v1/me/stats/published', {
+ method: 'PUT',
+ headers: { ...auth, 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
+ json: { published: { sites: false, activity: false, made: false, ai: false, money: false } },
+ });
+ assert.strictEqual(put.status, 200);
+ const api = await t.get(publicApi);
+ assert.strictEqual(api.status, 404);
+ assert.strictEqual(api.json().code, 'report.person_stats_not_found');
+ const page = await t.get('/u/goosely');
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<meta name="robots" content="noindex, nofollow">/);
+ assert.ok(page.text.includes('keeps their numbers private'));
+ assert.ok(!page.text.includes('Visits, 30 days'), 'no numbers on a private page');
+ const unknown = await t.get('/u/nobody');
+ assert.strictEqual(unknown.status, 404);
+ });
+
+ await check('the page form saves all five switches, then /me shows them', async () => {
+ const body = 'sites=true&activity=true&made=true&ai=true&money=true';
+ const post = await t.get('/api/v1/me/stats/published', {
+ method: 'POST',
+ headers: { ...auth, 'content-type': 'application/x-www-form-urlencoded', 'sec-fetch-site': 'same-origin' },
+ body,
+ });
+ assert.strictEqual(post.status, 303);
+ assert.strictEqual(post.headers.get('location'), '/me');
+ const page = await t.get('/u/goosely');
+ assert.ok(page.text.includes('Things made') && page.text.includes('id="rp-u-ai"') && page.text.includes('Vibes'), 'all five published');
+ const me = await t.get('/me', { headers: auth });
+ assert.ok(me.text.includes('Public now'));
+ const sitemap = await t.get('/sitemap.xml');
+ assert.ok(sitemap.text.includes('https://openvibe.report/u/goosely'), 'the public page is in the sitemap');
+ });
+
+ await check('the switches are same-origin and the person is never taken from the query', async () => {
+ const cross = await t.get('/api/v1/me/stats/published', {
+ method: 'PUT',
+ headers: { ...auth, 'content-type': 'application/json' },
+ json: { published: { sites: true, activity: true, made: true, ai: true, money: true } },
+ });
+ assert.strictEqual(cross.status, 403);
+ const bad = await t.get('/api/v1/me/stats/published', {
+ method: 'PUT',
+ headers: { ...auth, 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
+ json: { published: { sites: true } },
+ });
+ assert.strictEqual(bad.status, 422);
+ const other = await t.get('/api/v1/me/stats?subject=usr_01JTESTGOOSELY0000000000000', { headers: { cookie: `ov_token=${quiet}` } });
+ assert.strictEqual(other.json().person.username, 'quiet');
+ assert.strictEqual(viewed[viewed.length - 1].subject, 'usr_01JTESTQUIET00000000000000');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });