openvibers/openvibe.report Commits
You: your own numbers on openvibe.report, signed in (#30)
Changes
@@@ -14,7 +14,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.78.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0","pg": "^8.23.1"},@@@ -1072,9 +1072,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.70.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",-- "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",++ "version": "0.78.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.78.0",++ "integrity": "sha512-xOUpk7+K1gB3qhaXE0a+GpHkJZOJd+HKr7+EqfQwZqQxXr7fumMHt6/y2y0A1ersqHjZtx3WMvMc49bfBrBv2g==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -20,7 +20,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.78.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0","pg": "^8.23.1"},@@@ -263,3 +263,12 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex@supports selector(:has(*)) { @media (max-width: 640px) {.pick .rp-head-row h2 { display: none; }} }++++/* You (plan L41 R1): a person's own numbers. The sentence leads; the parts follow. */++.rp-you-say { max-width: 62ch; font-size: clamp(1.05rem, 2vw, 1.2rem); line-height: 1.55; color: var(--text-primary, #e6edf7); }++.rp-person > div { min-width: 0; }++.rp-tiles-sm { grid-template-columns: repeat(auto-fit, minmax(min(140px, 44%), 1fr)); margin-top: 12px; }++.rp-you-sub { margin: 26px 0 12px; font-size: 1rem; }++.rp-you-next { border-top: 1px solid var(--border, #1f2d47); padding-top: 18px; }++.rp-you-next h2 { font-size: 1rem; }++@media (max-width: 560px) { .rp-person { align-items: flex-start; } }@@@ -6,6 +6,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)++ * /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)* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)@@@ -13,7 +15,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. No sessions.++ * 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.*/const path = require('path');const express = require('express');@@@ -35,13 +38,14 @@ const { createPageRoutes, createPublicPages } = require('./http/pages');const { createDiscoveryRoutes } = require('./http/discovery');const { createInternalRoutes } = require('./http/internal');const { createServiceReadiness } = require('./observability');++const { createSsoClient } = require('openvibe-sdk/sso');const views = require('./views');const { PUBLIC_DIR, assetVersion } = require('./assets');const VERSION = require('../package.json').version;const PAGE_CACHE = 'public, max-age=120';--/** opts: config, store, fetchImpl, log, now, network, live, billing, analytics, shadow, releaseEnv */++/** opts: config, store, fetchImpl, log, now, network, live, billing, analytics, shadow, sso, releaseEnv */async function createApp(opts = {}) {const config = opts.config || configLib.load();const log = opts.log || console;@@@ -58,7 +62,14 @@ async function createApp(opts = {}) {const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);views.configure({ baseUrl: config.baseUrl });-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow };++ // A person's sign-in (the `report` client, the same id and secret Report already reads Billing with).++ const sso = opts.sso || createSsoClient({++ site: 'report', baseUrl: config.baseUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ networkUrl: config.networkPublicUrl, networkInternalUrl: config.networkInternalUrl,++ secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,++ });++++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso };const app = express();app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);@@@ -122,6 +133,10 @@ async function createApp(opts = {}) {},}));++ // ── Sign-in (/auth/login, /callback, /fedcm, /logout, /me, /refresh); req.user on every later route ──++ app.use('/auth', sso.router(express));++ app.use(sso.optionalAuth());++// ── Loopback-only operator and Events routes ────────────app.use('/internal', createInternalRoutes({ analytics, shadow, config, now, log }));@@@ -148,6 +148,20 @@ function createPageRoutes(ctx) {return send(req, res, views.recap({ recap }));});++ // 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) => {++ const user = req.user;++ if (!user || !isUsername(String(user.username || ''))) return send(req, res, views.youSignedOut(), 200, 'private, no-store');++ const days = normaliseDays(req.query.days);++ const [stats, recaps, speaker] = await Promise.all([++ ctx.analytics && typeof ctx.analytics.channel === 'function' ? ctx.analytics.channel(user.username, days).catch(() => null) : null,++ Promise.resolve(live.channelRecaps(user.username)).catch(() => null),++ Promise.resolve(live.speaker(user.username)).catch(() => null),++ ]);++ return send(req, res, views.you({ user, days, stats, recaps, speaker }), 200, 'private, no-store');++ });++r.get('/c/:username', async (req, res) => {const { username } = req.params;if (!isUsername(username)) return notFound(req, res);@@@ -75,6 +75,7 @@ const judgeWord = (j) => ({ human_pairwise: 'voted on blind, two at a time', rubconst gamesOf = (rows) => Math.round((rows || []).reduce((sum, r) => sum + (n(r && r.games) || 0), 0) / 2);const NAV = [++ { label: 'You', href: '/you', icon: 'fa-user' },{ label: 'Numbers', href: '/stats', icon: 'fa-chart-line' },{ label: 'The Rundown', href: '/rundown', icon: 'fa-newspaper' },{ label: 'AI boards', href: '/ai', icon: 'fa-robot' },@@@ -89,6 +90,10 @@ function layout({ title, description, canonical = null, path = null, robots = 'ilinks: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && active.startsWith(l.href)) })),appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }, { label: 'AI boards', href: '/ai', icon: 'fa-robot' }],history: { type: 'page', title: title || NAME },++ // Sign-in is this site's own (openvibe-sdk/sso at /auth): the navbar's buttons and its silent check use it.++ silentLogin: `${BASE}/auth/login?silent=1&next={url}`,++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',};const footer = { service: 'report', variant: 'full', mount: '#ov-footer', brandName: NAME };return shell.page({@@@ -356,6 +361,61 @@ ${cards.length ? `<ul class="rp-grid">${cards.map((c) => `<li>${c}</li>`).join('return layout({ title: `${username}: after-show reports`, description: `How each of ${username}'s streams went.`, body, active: '/rundown', path: `/c/${encodeURIComponent(username)}` });}++// ── You (plan L41 R1): a person's own numbers, signed in, in one place ──++// A sentence first, then each part that has something in it: streams, money, The Rundown. AI use, agents and++// machines join as their records reach Report (L40). Private: never cached, never indexed.++const plural = (v, one, many) => `${exact(v)} ${Number(v) === 1 ? one : many}`;++const hours = (sec) => { const h = (n(sec) || 0) / 3600; return h >= 10 ? `${Math.round(h)} h` : h >= 1 ? `${h.toFixed(1).replace(/\.0$/, '')} h` : `${Math.round(h * 60)} min`; };++++function youSentence({ name, days, stats, speaker }) {++ const T = (stats && stats.totals) || {};++ const parts = [];++ if (n(T.streams)) parts.push(`streamed ${plural(T.streams, 'time', 'times')} for ${hours(T.stream_seconds)}${n(T.peak_viewers) ? `, with up to ${plural(T.peak_viewers, 'viewer', 'viewers')} at once` : ''}`);++ if (n(T.messages)) parts.push(`drew ${plural(T.messages, 'chat message', 'chat messages')}${n(T.unique_chatters) ? ` from ${plural(T.unique_chatters, 'person', 'people')}` : ''}`);++ const rev = stats && stats.revenue;++ if (rev && n(rev.transactions)) parts.push(`received ${plural(rev.transactions, 'payment', 'payments')} in Vibes`);++ const eps = speaker && !speaker.opted_out ? (speaker.episodes || []).length : 0;++ if (eps) parts.push(`were written up in The Rundown ${plural(eps, 'time', 'times')}`);++ if (!parts.length) return `Nothing to count for you in the last ${days} days yet. Go live, and every stream gets its own report here.`;++ const list = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` : parts[0];++ return `In the last ${days} days, ${name}, you ${list}.`;++}++++function you({ user, days = 30, stats = null, recaps = null, speaker = null } = {}) {++ const name = String(user.display_name || user.username || 'you');++ const username = String(user.username || '');++ const T = (stats && stats.totals) || {};++ const tabs = [7, 30, 90].map((x) => `<a href="/you?days=${x}"${Number(days) === x ? ' aria-current="page"' : ''}>${x} days</a>`).join('');++ const watch = (stats && stats.daily || []).map((d) => ({ day: d.day, n: d.watch_minutes }));++ const cards = ((recaps && recaps.recaps) || []).slice(0, 6).map((x) => kit.card({ href: `/after-show/${x.stream_id}`, title: x.headline || x.title || 'Stream', peek: x.headline && x.title ? x.title : '', type: 'stream', image: x.thumbnail_url || '', badge: x.grade ? `Grade ${x.grade}` : '', corner: n(x.duration_seconds) ? mins(x.duration_seconds) : '', count: n(x.peak_viewers) ? [x.peak_viewers, 'peak'] : null, when: x.ended_at ? ts(x.ended_at) : null }));++ const streams = n(T.streams) || cards.length ? `<section aria-labelledby="rp-you-st"><h2 id="rp-you-st">Your streams</h2>++${n(T.streams) ? `<div class="rp-tiles rp-tiles-sm">${tile('Streams', exact(T.streams))}${tile('On air', hours(T.stream_seconds))}${tile('Most at once', exact(T.peak_viewers))}${tile('Average watching', T.avg_viewers != null ? exact(T.avg_viewers) : null)}${tile('Watch time', n(T.watch_minutes) ? `${fmt(T.watch_minutes)}<small> min</small>` : null)}${tile('Chat', n(T.messages) ? fmt(T.messages) : null, n(T.unique_chatters) ? `from ${plural(T.unique_chatters, 'person', 'people')}` : '')}</div>` : ''}++${watch.length > 1 ? bars(watch, 'Minutes watched a day', { h: 110 }) : ''}++${cards.length ? `<h3 class="rp-you-sub">After-show reports</h3><ul class="rp-grid">${cards.map((c) => `<li>${c}</li>`).join('')}</ul><p class="rp-meta"><a href="/c/${encodeURIComponent(username)}">All your reports</a></p>` : ''}++</section>` : '';++ const rev = stats && stats.revenue;++ const money = rev && n(rev.transactions) ? `<section aria-labelledby="rp-you-mo"><h2 id="rp-you-mo">Money</h2>++<p class="rp-lede">${plural(rev.transactions, 'payment', 'payments')} in Vibes reached you in the last ${esc(rev.days || days)} days${n(rev.refunded_bits) ? ', some refunded since' : ''}. OpenVibe.Billing keeps every amount and receipt: <a href="https://billing.openvibe.network/">see them there</a>.</p>++</section>` : '';++ const sp = speaker && !speaker.opted_out ? speaker : null;++ const rundown = sp && ((sp.topics || []).length || (sp.episodes || []).length) ? `<section aria-labelledby="rp-you-rd"><h2 id="rp-you-rd">In The Rundown</h2>++${(sp.topics || []).length ? `<ul class="rp-chips">${sp.topics.slice(0, 12).map((t) => `<li><a href="/rundown/t/${encodeURIComponent(t.slug)}">${esc(t.title)}</a></li>`).join('')}</ul>` : ''}++${(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>++${streams}${money}${rundown}++<section aria-labelledby="rp-you-next" class="rp-you-next"><h2 id="rp-you-next">Coming here</h2><p class="rp-fine">Your AI use across every OpenVibe site (tokens, how much came from cache, what it cost and whose key paid), 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' });++}++++function youSignedOut() {++ const body = `<header class="rp-page-h"><p class="rp-kicker">Your numbers</p><h1>Everything you do on OpenVibe, counted for you</h1>++<p class="rp-lede">Your streams and their after-show reports, the payments that reach you, The Rundown, and soon your AI use across every site. Only you see it.</p>++<p>${kit.button({ href: `/auth/login?next=${encodeURIComponent('/you')}`, label: 'Sign in to see yours', icon: 'forward' })}</p></header>`;++ return layout({ title: 'Your numbers', description: 'Sign in to see your own numbers across OpenVibe.', body, active: '/you', robots: 'noindex, nofollow' });++}++function developers() {const rows = [['GET /api/v1/stats?days=30', 'The network\'s open numbers over 7, 30 or 90 days: page views, accounts, Live, OpenCoins and every site.'],@@@ -501,4 +561,4 @@ 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, topic, episode, speaker, recap, channel, developers, aiIndex, aiBoard, aiModel, paretoRows, paretoChart, notFound, layout, setRelease, configure };++module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, paretoRows, paretoChart, notFound, layout, setRelease, configure };@@@ -167,6 +167,7 @@ async function boot(opts = {}) {now: opts.now,billing: opts.billing,analytics: opts.analytics,++ sso: opts.sso,shadow: opts.shadow,releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },});nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * You (plan L41 R1): a person's own numbers. Signed out, /you says what it is and offers a sign-in; signed in, it++ * reads their own channel's numbers, after-show reports, Billing's payment count and The Rundown, leads with one++ * sentence, and is never cached or indexed. Another person's token never shows someone else's numbers; a service's++ * token is not a session. Sign-in itself is openvibe-sdk/sso at /auth.++ */++const assert = require('assert');++const crypto = require('crypto');++const { createSsoClient } = require('openvibe-sdk/sso');++const { boot, check, done } = require('./helpers/boot');++++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' });++++(async () => {++ const asked = [];++ const analytics = {++ source: 'network',++ async channel(name, days) {++ asked.push([name, days]);++ return {++ channel: name, days, totals: { streams: 3, stream_seconds: 3 * 3600, peak_viewers: 41, avg_viewers: 12, unique_chatters: 9, messages: 230, watch_minutes: 2100 },++ daily: [{ day: '2026-10-29', watch_minutes: 900 }, { day: '2026-10-30', watch_minutes: 1200 }],++ streams: [], revenue: { days, transactions: 2, refunded_bits: 0 },++ };++ },++ stats: async () => null, metrics: async () => null, reportStats: () => null,++ };++ 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({ sso, analytics });++ try {++ await check('signed out: what it is, a sign-in back to /you, private and not indexed', async () => {++ const r = await t.get('/you');++ 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">/);++ assert.ok(r.text.includes('Everything you do on OpenVibe, counted for you'));++ assert.ok(r.text.includes('href="/auth/login?next=%2Fyou"'));++ assert.strictEqual(asked.length, 0, 'nothing is read for nobody');++ });++++ await check('signed in: their own numbers, a sentence first, then streams, money and The Rundown', async () => {++ const r = await t.get('/you?days=7', { headers: { cookie: `ov_token=${goosely}` } });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.deepStrictEqual(asked[asked.length - 1], ['goosely', 7], 'their own channel, the asked window');++ const html = r.text;++ assert.ok(html.includes('In the last 7 days, goosely, you streamed 3 times for 3 h, with up to 41 viewers at once, drew 230 chat messages from 9 people, received 2 payments in Vibes and were written up in The Rundown 1 time.'), 'the sentence');++ assert.ok(html.includes('<h2 id="rp-you-st">Your streams</h2>') && html.includes('Most at once'));++ assert.ok(html.includes('href="/after-show/7"'), 'their after-show reports');++ assert.ok(html.includes('2 payments in Vibes reached you') && html.includes('https://billing.openvibe.network/'), 'money says where the amounts are');++ assert.ok(html.includes('<h2 id="rp-you-rd">In The Rundown</h2>') && html.includes('href="/rundown/t/seattle"'));++ assert.ok(html.includes('aria-current="page">7 days'));++ assert.ok(html.includes('whose key paid'), 'what joins next (L40)');++ });++++ await check('only the parts with something in them: no streams or money, just The Rundown', async () => {++ const quiet = token({ sub: '8', subject_id: 'usr_01JTESTQUIET00000000000000', username: 'quiet', display_name: 'Quiet' });++ analytics.channel = async () => null;++ const r = await t.get('/you', { headers: { cookie: `ov_token=${quiet}` } });++ assert.ok(r.text.includes('In the last 30 days, Quiet, you were written up in The Rundown 1 time.'));++ assert.ok(!r.text.includes('Most at once') && !r.text.includes('id="rp-you-mo"'), 'no tiles without numbers, no money without payments');++ });++++ await check('someone with nothing yet gets a plain sentence', async () => {++ const views = require('../server/views');++ const html = views.you({ user: { username: 'new', display_name: 'New' }, days: 30 });++ assert.ok(html.includes('Nothing to count for you in the last 30 days yet. Go live, and every stream gets its own report here.'));++ });++++ await check('a service token or a forged one is not a session', async () => {++ const svc = token({ sub: 'svc:live', actor_type: 'service', username: 'goosely' });++ assert.ok((await t.get('/you', { headers: { cookie: `ov_token=${svc}` } })).text.includes('Sign in to see yours'));++ const forged = goosely.slice(0, -4) + 'AAAA';++ assert.ok((await t.get('/you', { headers: { cookie: `ov_token=${forged}` } })).text.includes('Sign in to see yours'));++ });++++ await check('sign-in lives at /auth, and the navbar knows it', async () => {++ const login = await t.get('/auth/login?next=/you');++ assert.strictEqual(login.status, 302);++ assert.match(login.headers.get('location'), /^https:\/\/openvibe\.network\/oauth\/authorize\?.*client_id=report/);++ const page = await t.get('/stats');++ assert.ok(page.text.includes('"loginUrl":"/auth/login?next={path}"') && page.text.includes('"href":"/you"'));++ assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=120', 'public pages stay public');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"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.225.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.78.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
},
},
@@ -1072,9 +1072,9 @@
@@ -1072,9 +1072,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.70.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
- "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",
+ "version": "0.78.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.78.0",
+ "integrity": "sha512-xOUpk7+K1gB3qhaXE0a+GpHkJZOJd+HKr7+EqfQwZqQxXr7fumMHt6/y2y0A1ersqHjZtx3WMvMc49bfBrBv2g==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"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.225.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.225.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.78.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"pg": "^8.23.1"
"pg": "^8.23.1"
},
},
@@ -263,3 +263,12 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -263,3 +263,12 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@supports selector(:has(*)) { @media (max-width: 640px) {
@supports selector(:has(*)) { @media (max-width: 640px) {
.pick .rp-head-row h2 { display: none; }
.pick .rp-head-row h2 { display: none; }
} }
} }
+
+/* You (plan L41 R1): a person's own numbers. The sentence leads; the parts follow. */
+.rp-you-say { max-width: 62ch; font-size: clamp(1.05rem, 2vw, 1.2rem); line-height: 1.55; color: var(--text-primary, #e6edf7); }
+.rp-person > div { min-width: 0; }
+.rp-tiles-sm { grid-template-columns: repeat(auto-fit, minmax(min(140px, 44%), 1fr)); margin-top: 12px; }
+.rp-you-sub { margin: 26px 0 12px; font-size: 1rem; }
+.rp-you-next { border-top: 1px solid var(--border, #1f2d47); padding-top: 18px; }
+.rp-you-next h2 { font-size: 1rem; }
+@media (max-width: 560px) { .rp-person { align-items: flex-start; } }
@@ -6,6 +6,8 @@
@@ -6,6 +6,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)
+ * /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)
* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
* /robots.txt, /sitemap.xml, /llms.txt crawl artifacts (http/discovery.js)
@@ -13,7 +15,8 @@
@@ -13,7 +15,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. No sessions.
+ * 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.
*/
*/
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -35,13 +38,14 @@ const { createPageRoutes, createPublicPages } = require('./http/pages');
@@ -35,13 +38,14 @@ const { createPageRoutes, createPublicPages } = require('./http/pages');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createInternalRoutes } = require('./http/internal');
const { createInternalRoutes } = require('./http/internal');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
+const { createSsoClient } = require('openvibe-sdk/sso');
const views = require('./views');
const views = require('./views');
const { PUBLIC_DIR, assetVersion } = require('./assets');
const { PUBLIC_DIR, assetVersion } = require('./assets');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
const PAGE_CACHE = 'public, max-age=120';
const PAGE_CACHE = 'public, max-age=120';
-/** opts: config, store, fetchImpl, log, now, network, live, billing, analytics, shadow, releaseEnv */
+/** opts: config, store, fetchImpl, log, now, network, live, billing, analytics, shadow, sso, releaseEnv */
async function createApp(opts = {}) {
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
const log = opts.log || console;
const log = opts.log || console;
@@ -58,7 +62,14 @@ async function createApp(opts = {}) {
@@ -58,7 +62,14 @@ 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);
views.configure({ baseUrl: config.baseUrl });
views.configure({ baseUrl: config.baseUrl });
- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow };
+ // A person's sign-in (the `report` client, the same id and secret Report already reads Billing with).
+ const sso = opts.sso || createSsoClient({
+ site: 'report', baseUrl: config.baseUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ networkUrl: config.networkPublicUrl, networkInternalUrl: config.networkInternalUrl,
+ secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
+ });
+
+ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, analytics, shadow, sso };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -122,6 +133,10 @@ async function createApp(opts = {}) {
@@ -122,6 +133,10 @@ async function createApp(opts = {}) {
},
},
}));
}));
+ // ── Sign-in (/auth/login, /callback, /fedcm, /logout, /me, /refresh); req.user on every later route ──
+ app.use('/auth', sso.router(express));
+ app.use(sso.optionalAuth());
+
// ── Loopback-only operator and Events routes ────────────
// ── Loopback-only operator and Events routes ────────────
app.use('/internal', createInternalRoutes({ analytics, shadow, config, now, log }));
app.use('/internal', createInternalRoutes({ analytics, shadow, config, now, log }));
@@ -148,6 +148,20 @@ function createPageRoutes(ctx) {
@@ -148,6 +148,20 @@ function createPageRoutes(ctx) {
return send(req, res, views.recap({ recap }));
return send(req, res, views.recap({ recap }));
});
});
+ // 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) => {
+ const user = req.user;
+ if (!user || !isUsername(String(user.username || ''))) return send(req, res, views.youSignedOut(), 200, 'private, no-store');
+ const days = normaliseDays(req.query.days);
+ const [stats, recaps, speaker] = await Promise.all([
+ ctx.analytics && typeof ctx.analytics.channel === 'function' ? ctx.analytics.channel(user.username, days).catch(() => null) : null,
+ Promise.resolve(live.channelRecaps(user.username)).catch(() => null),
+ Promise.resolve(live.speaker(user.username)).catch(() => null),
+ ]);
+ return send(req, res, views.you({ user, days, stats, recaps, speaker }), 200, 'private, no-store');
+ });
+
r.get('/c/:username', async (req, res) => {
r.get('/c/:username', async (req, res) => {
const { username } = req.params;
const { username } = req.params;
if (!isUsername(username)) return notFound(req, res);
if (!isUsername(username)) return notFound(req, res);
@@ -75,6 +75,7 @@ const judgeWord = (j) => ({ human_pairwise: 'voted on blind, two at a time', rub
@@ -75,6 +75,7 @@ const judgeWord = (j) => ({ human_pairwise: 'voted on blind, two at a time', rub
const gamesOf = (rows) => Math.round((rows || []).reduce((sum, r) => sum + (n(r && r.games) || 0), 0) / 2);
const gamesOf = (rows) => Math.round((rows || []).reduce((sum, r) => sum + (n(r && r.games) || 0), 0) / 2);
const NAV = [
const NAV = [
+ { label: 'You', href: '/you', icon: 'fa-user' },
{ label: 'Numbers', href: '/stats', icon: 'fa-chart-line' },
{ label: 'Numbers', href: '/stats', icon: 'fa-chart-line' },
{ label: 'The Rundown', href: '/rundown', icon: 'fa-newspaper' },
{ label: 'The Rundown', href: '/rundown', icon: 'fa-newspaper' },
{ label: 'AI boards', href: '/ai', icon: 'fa-robot' },
{ label: 'AI boards', href: '/ai', icon: 'fa-robot' },
@@ -89,6 +90,10 @@ function layout({ title, description, canonical = null, path = null, robots = 'i
@@ -89,6 +90,10 @@ function layout({ title, description, canonical = null, path = null, robots = 'i
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && active.startsWith(l.href)) })),
links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && active.startsWith(l.href)) })),
appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }, { label: 'AI boards', href: '/ai', icon: 'fa-robot' }],
appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Numbers', href: '/stats', icon: 'fa-chart-line' }, { label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' }, { label: 'AI boards', href: '/ai', icon: 'fa-robot' }],
history: { type: 'page', title: title || NAME },
history: { type: 'page', title: title || NAME },
+ // Sign-in is this site's own (openvibe-sdk/sso at /auth): the navbar's buttons and its silent check use it.
+ silentLogin: `${BASE}/auth/login?silent=1&next={url}`,
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
};
};
const footer = { service: 'report', variant: 'full', mount: '#ov-footer', brandName: NAME };
const footer = { service: 'report', variant: 'full', mount: '#ov-footer', brandName: NAME };
return shell.page({
return shell.page({
@@ -356,6 +361,61 @@ ${cards.length ? `<ul class="rp-grid">${cards.map((c) => `<li>${c}</li>`).join('
@@ -356,6 +361,61 @@ ${cards.length ? `<ul class="rp-grid">${cards.map((c) => `<li>${c}</li>`).join('
return layout({ title: `${username}: after-show reports`, description: `How each of ${username}'s streams went.`, body, active: '/rundown', path: `/c/${encodeURIComponent(username)}` });
return layout({ title: `${username}: after-show reports`, description: `How each of ${username}'s streams went.`, body, active: '/rundown', path: `/c/${encodeURIComponent(username)}` });
}
}
+// ── You (plan L41 R1): a person's own numbers, signed in, in one place ──
+// A sentence first, then each part that has something in it: streams, money, The Rundown. AI use, agents and
+// machines join as their records reach Report (L40). Private: never cached, never indexed.
+const plural = (v, one, many) => `${exact(v)} ${Number(v) === 1 ? one : many}`;
+const hours = (sec) => { const h = (n(sec) || 0) / 3600; return h >= 10 ? `${Math.round(h)} h` : h >= 1 ? `${h.toFixed(1).replace(/\.0$/, '')} h` : `${Math.round(h * 60)} min`; };
+
+function youSentence({ name, days, stats, speaker }) {
+ const T = (stats && stats.totals) || {};
+ const parts = [];
+ if (n(T.streams)) parts.push(`streamed ${plural(T.streams, 'time', 'times')} for ${hours(T.stream_seconds)}${n(T.peak_viewers) ? `, with up to ${plural(T.peak_viewers, 'viewer', 'viewers')} at once` : ''}`);
+ if (n(T.messages)) parts.push(`drew ${plural(T.messages, 'chat message', 'chat messages')}${n(T.unique_chatters) ? ` from ${plural(T.unique_chatters, 'person', 'people')}` : ''}`);
+ const rev = stats && stats.revenue;
+ if (rev && n(rev.transactions)) parts.push(`received ${plural(rev.transactions, 'payment', 'payments')} in Vibes`);
+ const eps = speaker && !speaker.opted_out ? (speaker.episodes || []).length : 0;
+ if (eps) parts.push(`were written up in The Rundown ${plural(eps, 'time', 'times')}`);
+ if (!parts.length) return `Nothing to count for you in the last ${days} days yet. Go live, and every stream gets its own report here.`;
+ const list = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` : parts[0];
+ return `In the last ${days} days, ${name}, you ${list}.`;
+}
+
+function you({ user, days = 30, stats = null, recaps = null, speaker = null } = {}) {
+ const name = String(user.display_name || user.username || 'you');
+ const username = String(user.username || '');
+ const T = (stats && stats.totals) || {};
+ const tabs = [7, 30, 90].map((x) => `<a href="/you?days=${x}"${Number(days) === x ? ' aria-current="page"' : ''}>${x} days</a>`).join('');
+ const watch = (stats && stats.daily || []).map((d) => ({ day: d.day, n: d.watch_minutes }));
+ const cards = ((recaps && recaps.recaps) || []).slice(0, 6).map((x) => kit.card({ href: `/after-show/${x.stream_id}`, title: x.headline || x.title || 'Stream', peek: x.headline && x.title ? x.title : '', type: 'stream', image: x.thumbnail_url || '', badge: x.grade ? `Grade ${x.grade}` : '', corner: n(x.duration_seconds) ? mins(x.duration_seconds) : '', count: n(x.peak_viewers) ? [x.peak_viewers, 'peak'] : null, when: x.ended_at ? ts(x.ended_at) : null }));
+ const streams = n(T.streams) || cards.length ? `<section aria-labelledby="rp-you-st"><h2 id="rp-you-st">Your streams</h2>
+${n(T.streams) ? `<div class="rp-tiles rp-tiles-sm">${tile('Streams', exact(T.streams))}${tile('On air', hours(T.stream_seconds))}${tile('Most at once', exact(T.peak_viewers))}${tile('Average watching', T.avg_viewers != null ? exact(T.avg_viewers) : null)}${tile('Watch time', n(T.watch_minutes) ? `${fmt(T.watch_minutes)}<small> min</small>` : null)}${tile('Chat', n(T.messages) ? fmt(T.messages) : null, n(T.unique_chatters) ? `from ${plural(T.unique_chatters, 'person', 'people')}` : '')}</div>` : ''}
+${watch.length > 1 ? bars(watch, 'Minutes watched a day', { h: 110 }) : ''}
+${cards.length ? `<h3 class="rp-you-sub">After-show reports</h3><ul class="rp-grid">${cards.map((c) => `<li>${c}</li>`).join('')}</ul><p class="rp-meta"><a href="/c/${encodeURIComponent(username)}">All your reports</a></p>` : ''}
+</section>` : '';
+ const rev = stats && stats.revenue;
+ const money = rev && n(rev.transactions) ? `<section aria-labelledby="rp-you-mo"><h2 id="rp-you-mo">Money</h2>
+<p class="rp-lede">${plural(rev.transactions, 'payment', 'payments')} in Vibes reached you in the last ${esc(rev.days || days)} days${n(rev.refunded_bits) ? ', some refunded since' : ''}. OpenVibe.Billing keeps every amount and receipt: <a href="https://billing.openvibe.network/">see them there</a>.</p>
+</section>` : '';
+ const sp = speaker && !speaker.opted_out ? speaker : null;
+ const rundown = sp && ((sp.topics || []).length || (sp.episodes || []).length) ? `<section aria-labelledby="rp-you-rd"><h2 id="rp-you-rd">In The Rundown</h2>
+${(sp.topics || []).length ? `<ul class="rp-chips">${sp.topics.slice(0, 12).map((t) => `<li><a href="/rundown/t/${encodeURIComponent(t.slug)}">${esc(t.title)}</a></li>`).join('')}</ul>` : ''}
+${(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>
+${streams}${money}${rundown}
+<section aria-labelledby="rp-you-next" class="rp-you-next"><h2 id="rp-you-next">Coming here</h2><p class="rp-fine">Your AI use across every OpenVibe site (tokens, how much came from cache, what it cost and whose key paid), 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' });
+}
+
+function youSignedOut() {
+ const body = `<header class="rp-page-h"><p class="rp-kicker">Your numbers</p><h1>Everything you do on OpenVibe, counted for you</h1>
+<p class="rp-lede">Your streams and their after-show reports, the payments that reach you, The Rundown, and soon your AI use across every site. Only you see it.</p>
+<p>${kit.button({ href: `/auth/login?next=${encodeURIComponent('/you')}`, label: 'Sign in to see yours', icon: 'forward' })}</p></header>`;
+ return layout({ title: 'Your numbers', description: 'Sign in to see your own numbers across OpenVibe.', body, active: '/you', robots: 'noindex, nofollow' });
+}
+
function developers() {
function developers() {
const rows = [
const rows = [
['GET /api/v1/stats?days=30', 'The network\'s open numbers over 7, 30 or 90 days: page views, accounts, Live, OpenCoins and every site.'],
['GET /api/v1/stats?days=30', 'The network\'s open numbers over 7, 30 or 90 days: page views, accounts, Live, OpenCoins and every site.'],
@@ -501,4 +561,4 @@ function notFound() {
@@ -501,4 +561,4 @@ 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, topic, episode, speaker, recap, channel, developers, aiIndex, aiBoard, aiModel, paretoRows, paretoChart, notFound, layout, setRelease, configure };
+module.exports = { home, stats, rundown, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, paretoRows, paretoChart, notFound, layout, setRelease, configure };
@@ -167,6 +167,7 @@ async function boot(opts = {}) {
@@ -167,6 +167,7 @@ async function boot(opts = {}) {
now: opts.now,
now: opts.now,
billing: opts.billing,
billing: opts.billing,
analytics: opts.analytics,
analytics: opts.analytics,
+ sso: opts.sso,
shadow: opts.shadow,
shadow: opts.shadow,
releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },
releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * You (plan L41 R1): a person's own numbers. Signed out, /you says what it is and offers a sign-in; signed in, it
+ * reads their own channel's numbers, after-show reports, Billing's payment count and The Rundown, leads with one
+ * sentence, and is never cached or indexed. Another person's token never shows someone else's numbers; a service's
+ * token is not a session. Sign-in itself is openvibe-sdk/sso at /auth.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { createSsoClient } = require('openvibe-sdk/sso');
+const { boot, check, done } = require('./helpers/boot');
+
+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' });
+
+(async () => {
+ const asked = [];
+ const analytics = {
+ source: 'network',
+ async channel(name, days) {
+ asked.push([name, days]);
+ return {
+ channel: name, days, totals: { streams: 3, stream_seconds: 3 * 3600, peak_viewers: 41, avg_viewers: 12, unique_chatters: 9, messages: 230, watch_minutes: 2100 },
+ daily: [{ day: '2026-10-29', watch_minutes: 900 }, { day: '2026-10-30', watch_minutes: 1200 }],
+ streams: [], revenue: { days, transactions: 2, refunded_bits: 0 },
+ };
+ },
+ stats: async () => null, metrics: async () => null, reportStats: () => null,
+ };
+ 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({ sso, analytics });
+ try {
+ await check('signed out: what it is, a sign-in back to /you, private and not indexed', async () => {
+ const r = await t.get('/you');
+ 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">/);
+ assert.ok(r.text.includes('Everything you do on OpenVibe, counted for you'));
+ assert.ok(r.text.includes('href="/auth/login?next=%2Fyou"'));
+ assert.strictEqual(asked.length, 0, 'nothing is read for nobody');
+ });
+
+ await check('signed in: their own numbers, a sentence first, then streams, money and The Rundown', async () => {
+ const r = await t.get('/you?days=7', { headers: { cookie: `ov_token=${goosely}` } });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.deepStrictEqual(asked[asked.length - 1], ['goosely', 7], 'their own channel, the asked window');
+ const html = r.text;
+ assert.ok(html.includes('In the last 7 days, goosely, you streamed 3 times for 3 h, with up to 41 viewers at once, drew 230 chat messages from 9 people, received 2 payments in Vibes and were written up in The Rundown 1 time.'), 'the sentence');
+ assert.ok(html.includes('<h2 id="rp-you-st">Your streams</h2>') && html.includes('Most at once'));
+ assert.ok(html.includes('href="/after-show/7"'), 'their after-show reports');
+ assert.ok(html.includes('2 payments in Vibes reached you') && html.includes('https://billing.openvibe.network/'), 'money says where the amounts are');
+ assert.ok(html.includes('<h2 id="rp-you-rd">In The Rundown</h2>') && html.includes('href="/rundown/t/seattle"'));
+ assert.ok(html.includes('aria-current="page">7 days'));
+ assert.ok(html.includes('whose key paid'), 'what joins next (L40)');
+ });
+
+ await check('only the parts with something in them: no streams or money, just The Rundown', async () => {
+ const quiet = token({ sub: '8', subject_id: 'usr_01JTESTQUIET00000000000000', username: 'quiet', display_name: 'Quiet' });
+ analytics.channel = async () => null;
+ const r = await t.get('/you', { headers: { cookie: `ov_token=${quiet}` } });
+ assert.ok(r.text.includes('In the last 30 days, Quiet, you were written up in The Rundown 1 time.'));
+ assert.ok(!r.text.includes('Most at once') && !r.text.includes('id="rp-you-mo"'), 'no tiles without numbers, no money without payments');
+ });
+
+ await check('someone with nothing yet gets a plain sentence', async () => {
+ const views = require('../server/views');
+ const html = views.you({ user: { username: 'new', display_name: 'New' }, days: 30 });
+ assert.ok(html.includes('Nothing to count for you in the last 30 days yet. Go live, and every stream gets its own report here.'));
+ });
+
+ await check('a service token or a forged one is not a session', async () => {
+ const svc = token({ sub: 'svc:live', actor_type: 'service', username: 'goosely' });
+ assert.ok((await t.get('/you', { headers: { cookie: `ov_token=${svc}` } })).text.includes('Sign in to see yours'));
+ const forged = goosely.slice(0, -4) + 'AAAA';
+ assert.ok((await t.get('/you', { headers: { cookie: `ov_token=${forged}` } })).text.includes('Sign in to see yours'));
+ });
+
+ await check('sign-in lives at /auth, and the navbar knows it', async () => {
+ const login = await t.get('/auth/login?next=/you');
+ assert.strictEqual(login.status, 302);
+ assert.match(login.headers.get('location'), /^https:\/\/openvibe\.network\/oauth\/authorize\?.*client_id=report/);
+ const page = await t.get('/stats');
+ assert.ok(page.text.includes('"loginUrl":"/auth/login?next={path}"') && page.text.includes('"href":"/you"'));
+ assert.strictEqual(page.headers.get('cache-control'), 'public, max-age=120', 'public pages stay public');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });