openvibers/openvibe.report Commits

You: your own numbers on openvibe.report, signed in (#30)

OP OpenVibers committed 13 days ago · 0051c6100d · 8 files, +207 −9

Report signs people in with openvibe-sdk/sso (the report client it already
reads Billing with; SDK 0.78.0) at /auth, and the navbar uses it. /you leads
with one sentence over 7, 30 or 90 days, then only the parts with something in
them: your streams (tiles, minutes watched a day, after-show reports), the
payments Billing says reached you (amounts stay on Billing), and The Rundown.
AI use, agents and machines join as their records reach Report (L40). Private:
no-store and noindex; service and forged tokens are not sessions.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex fbfbc52..8d4c270 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 0d61c33..c5ed09c 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/public/css/app.css b/public/css/app.css
iindex 5d3c04c..01a421a 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -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; } }
ddiff --git a/server/app.js b/server/app.js
iindex d194507..6a24746 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 47b8b9c..ab58de6 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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);
ddiff --git a/server/views/index.js b/server/views/index.js
iindex f669a15..706f504 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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 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 = 'i
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' }],
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 };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex b48068a..b3f1be8 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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' },
});
ddiff --git a/test/you.test.js b/test/you.test.js
nnew file mode 100644
iindex 0000000..7a27a49
---- /dev/null
++++ b/test/you.test.js
@@@ -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); });