openvibers/openvibe.report Commits
Agent Streams S7: chapter links, topic citations and the third-party report door (#112)
Changes
@@@ -251,6 +251,29 @@ async function createApp(opts = {}) {// ── Loopback-only operator and Events routes ────────────app.use('/internal', createInternalRoutes({ analytics, shadow, config, now, log }));++ // ── A session report from any harness with the person's own token (Agent Streams S7) ──++ // openvibe-sdk/reports writes here. The Report service takes the owner from the token, never from the body; the++ // report is private by default; a browser call must be same-origin, while a server-to-server call carries a Bearer.++ app.post('/api/v1/me/session-reports', express.json({ limit: '256kb' }), async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ const viewer = sharing.viewerOf(req.user);++ if (!viewer) return contracts.http.sendProblem(res, 401, 'auth.sign_in_required', { detail: 'Sign in to write your report.', ctx: req.ov });++ const bearer = /^Bearer\s+\S+/i.test(String(req.headers.authorization || ''));++ const sameSite = ['same-origin', 'same-site'].includes(String(req.headers['sec-fetch-site'] || ''));++ if (!bearer && !sameSite && !req.headers['x-requested-with']) {++ return contracts.http.sendProblem(res, 403, 'share.csrf', { detail: 'Cross-site request refused', ctx: req.ov });++ }++ if (!sessionReports) return contracts.http.sendProblem(res, 503, 'report.no_database', { detail: 'Report has no database in this process.', ctx: req.ov });++ const body = req.body && typeof req.body === 'object' ? { ...req.body } : {};++ delete body.owner;++ try {++ const out = await sessionReports.write({ ...body, owner: viewer.subject.id });++ return res.status(out.created ? 201 : 200).json(out);++ } catch (err) {++ return contracts.http.sendProblem(res, 400, 'report.invalid', { detail: String((err && err.message) || err).slice(0, 300), ctx: req.ov });++ }++ });++// ── API: CORS-open, public for two minutes, 120 requests a minute per address ──app.use('/api/v1', (_req, res, next) => {res.set('Access-Control-Allow-Origin', '*');@@@ -124,6 +124,19 @@ function createApi({ network, live, analytics = null, aiUsage = null, aiExchangereturn res.json(recaps);});++ // 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) => {++ if (!sessionReports) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');++ const viewer = sharing.viewerOf(req.user);++ if (!viewer) return problem(req, res, 401, 'auth.sign_in_required', 'Sign in to read your agents.');++ const days = Math.min(30, Math.max(1, Number(req.query.days) || 7));++ const from = now() - days * 24 * 60 * 60_000;++ const reports = (await sessionReports.listForOwner(viewer.subject.id, { limit: 200 }))++ .filter((r) => { const at = Date.parse(r.created_at); return !Number.isFinite(at) || at >= from; });++ return res.json({ days, reports });++ });++// ── Session reports (Agent Streams S3), in the sharing system's four audiences ─────────────────────//// The share row decides through canView, exactly as the page does: a report of Specific people is a 404 for a@@@ -186,7 +186,9 @@ function createPageRoutes(ctx) {if (!isSlug(slug)) return notFound(req, res);const topic = await live.topic(slug);if (!topic) return notFound(req, res);-- return send(req, res, views.topic({ topic }));++ // S7: a public agent report may cite the topic when the agent worked on it (only Everyone's reports).++ const citations = sessionReports ? await sessionReports.listPublicMatchingTopic((topic.topic && topic.topic.title) || topic.title).catch(() => []) : [];++ return send(req, res, views.topic({ topic, citations }));});r.get('/rundown/e/:streamId', async (req, res) => {@@@ -306,6 +306,23 @@ function createSessionReports({ store, config, log = console, sharing = null })return all.filter((r) => String(r.created_at || '').slice(0, 10) === day).slice(0, limit);}++ /**++ * The public reports whose task says the agent worked on this topic (S7). Only Everyone's reports are read++ * (listPublic is the sharing system's public answer), and the topic's whole name must appear as a whole phrase:++ * "login" is not "login page", and a name inside another word is never a match.++ */++ async function listPublicMatchingTopic(title, { limit = 6 } = {}) {++ const phrase = normalizeTopic(title);++ if (!phrase) return [];++ const all = await listPublic({ limit: 500 });++ const re = new RegExp(`(^| )${phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}( |$)`);++ return all.filter((r) => {++ const text = [r.task && r.task.title, r.summary, ...(Array.isArray(r.highlights) ? r.highlights : [])]++ .map(normalizeTopic).filter(Boolean).join(' ');++ return re.test(text);++ }).slice(0, Math.max(1, Math.min(20, Number(limit) || 6)));++ }++/** A person's own reports, newest first (their page and their default's "apply to all"). */async function listForOwner(owner, { limit = 24 } = {}) {if (!db || !owner) return [];@@@ -337,8 +354,13 @@ function createSessionReports({ store, config, log = console, sharing = null })return {write, writeStream, get, getWithAudience, listPublic, listPublicToday, listForOwner, attachStream, urlFor,-- defaultAudience, setDefaultAudience, startingAudience,++ defaultAudience, setDefaultAudience, startingAudience, listPublicMatchingTopic,};}--module.exports = { createSessionReports, cleanBody, scrub, scrubDeep, rowToReport, ID_RE };\\ No newline at end of file++/** A topic or a report's text as words: lower case, accents folded, punctuation and markup gone. */++function normalizeTopic(value) {++ return String(value || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, ' ').trim();++}++++module.exports = { createSessionReports, cleanBody, scrub, scrubDeep, rowToReport, normalizeTopic, ID_RE };\\ No newline at end of file@@@ -450,21 +450,43 @@ function sharingChip(audience, members = []) {return 'Only you';}--function topic({ topic: d } = {}) {++function topic({ topic: d, citations = [] } = {}) {const t = d.topic || {};const takes = (d.takes || []).slice(0, 40);++ // S7: a public agent report whose task worked on this topic, under the whole-name rule. Only Everyone's reports++ // reach here (the page asks listPublicMatchingTopic); a hidden report is never a citation.++ const cites = (Array.isArray(citations) ? citations : []).slice(0, 6);++ const citesHTML = cites.length ? `<section aria-labelledby="rp-cite-h"><h2 id="rp-cite-h">Agents that worked on this</h2><ul class="rp-topics">${cites.map((r) => `<li class="rp-report"><a href="/s/${encodeURIComponent(r.id)}"><span class="rp-kind">${esc((r.outcome || 'done') === 'failed' ? 'Failed' : (r.outcome || 'done') === 'stopped' ? 'Stopped' : 'Done')}</span><b>${esc((r.task && r.task.title) || 'An agent session')}</b><span class="rp-report-s">${esc(r.summary || '')}</span><span class="rp-meta">${AI}${r.worked_seconds ? ` · ${esc(worked(r))}` : ''}</span></a></li>`).join('')}</ul></section>` : '';const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · ${esc(KIND[t.kind] || t.kind || 'Topic')}</p><h1>${esc(t.title)}</h1>${t.headline ? `<p class="rp-lede">${esc(t.headline)}</p>` : ''}${t.summary ? `<p class="rp-summary">${esc(t.summary)}</p>` : ''}<p class="rp-meta">${AI}${t.first_seen_at ? ` · since ${esc(day(t.first_seen_at))}` : ''}</p></header>${takes.length ? `<section aria-labelledby="rp-tk-h"><h2 id="rp-tk-h">Takes</h2><ol class="rp-takes">${takes.map(takeRow).join('')}</ol></section>` : ''}++${citesHTML}${(d.related || []).length ? `<section aria-labelledby="rp-rel-h"><h2 id="rp-rel-h">Related</h2><ul class="rp-chips">${d.related.slice(0, 12).map((r) => `<li><a href="/rundown/t/${encodeURIComponent(r.slug)}">${esc(r.title)}</a></li>`).join('')}</ul></section>` : ''}`;return layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}` });}--function episode({ episode: d } = {}) {++/** A chapter's moment in the recording on OpenVibe.Video (the same link Live's own Rundown draws). */++function chapterHref(vodId, sec) {++ const id = String(vodId == null ? '' : vodId).trim();++ if (!/^\d{1,16}$/.test(id)) return null;++ const t = Math.max(0, Math.floor(Number(sec) || 0));++ return `https://openvibe.video/v/${encodeURIComponent(id)}${t ? `?t=${t}` : ''}`;++}++++function episode({ episode: d, citations = [] } = {}) {const e = d.episode || {}, s = d.stream || {};const segs = (d.segments || []).slice(0, 60);++ // S7: the chapters are the recording's moments, linked into OpenVibe.Video; a chapter with no recording stays text.++ const vod = e.vod_id || s.vod_id || null;++ const chapters = (Array.isArray(e.chapters) ? e.chapters : []).slice(0, 40);++ const chaptersHTML = chapters.length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Chapters</h2><ol class="rp-chapters">${chapters.map((c) => {++ const href = vod ? chapterHref(vod, c.sec) : null;++ const stamp = `<span class="rp-stamp">${esc(String(Math.floor((Number(c.sec) || 0) / 60)).padStart(2, '0'))}:${esc(String((Number(c.sec) || 0) % 60).padStart(2, '0'))}</span>`;++ return `<li>${href ? `<a href="${esc(href)}">${stamp} ${esc(c.title)}</a>` : `${stamp} ${esc(c.title)}`}</li>`;++ }).join('')}</ol></section>` : '';const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · episode</p><h1>${esc(e.title || s.title || 'Episode')}</h1>${e.logline ? `<p class="rp-lede">${esc(e.logline)}</p>` : ''}<p class="rp-meta">${AI}${who(s.user) ? ` · <a href="/rundown/s/${encodeURIComponent(s.user.username)}">${esc(who(s.user))}</a>` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${s.id ? ` · <a href="/after-show/${encodeURIComponent(s.id)}">After-show report</a>` : ''}</p></header>++${chaptersHTML}${segs.length ? `<section aria-labelledby="rp-sg-h"><h2 id="rp-sg-h">What happened</h2><ol class="rp-takes rp-timeline">${segs.map(takeRow).join('')}</ol></section>` : ''}${(d.more || []).length ? `<section aria-labelledby="rp-mo-h"><h2 id="rp-mo-h">More from ${esc(who(s.user) || 'this streamer')}</h2><ul class="rp-chips">${d.more.slice(0, 8).map((m) => `<li><a href="/rundown/e/${encodeURIComponent(m.stream_id)}">${esc(m.title)}</a></li>`).join('')}</ul></section>` : ''}`;return layout({ title: `${e.title || s.title}: The Rundown`, description: e.logline || '', body, active: '/rundown', path: `/rundown/e/${encodeURIComponent(s.id || e.stream_id || '')}` });nnew file mode 100644@@@ -0,0 +1,89 @@++'use strict';++/**++ * Report, S7: chapter links and topic citations, and the third-party report door.++ *++ * - an episode's chapters link into OpenVibe.Video at their second, from the episode's own vod_id;++ * - a topic page cites a public agent report when the agent worked on the topic's whole name, never a hidden report++ * and never a partial name;++ * - POST /api/v1/me/session-reports writes with the person's token: the owner comes from the token, never the body,++ * and the report is private by default; GET /api/v1/me/session-reports is only that person's.++ */++const assert = require('assert');++const express = require('express');++const { boot, check, done } = require('./helpers/boot');++const views = require('../server/views');++++const OWNER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPS';++const OTHER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPT';++++const sso = {++ router: () => express.Router(),++ optionalAuth: () => (req, _res, next) => {++ if (req.headers['x-test-user'] === 'owner') req.user = { subject_id: OWNER, role: 'user', username: 'ann' };++ next();++ },++};++const verifyServiceToken = async (token) => (token === 'good'++ ? { ok: true, claims: { sub: 'svc:codes', cap: ['report.session.write'], aud: ['openvibe.report'] } }++ : { ok: false, code: 'token.invalid' });++const report = (over = {}) => ({++ owner: OWNER, source: 'codes', task: { ref: 'tsk_' + Math.random().toString(36).slice(2), title: 'Fix the checkout page' },++ summary: 'The agent fixed the checkout page and ran the tests.', visibility: 'public', ...over,++});++++(async () => {++ const t = await boot({ db: true, sso, verifyServiceToken, now: () => Date.parse('2026-11-07T12:00:00Z') });++ try {++ await check('the third-party door takes the owner from the token, not the body', async () => {++ const r = await t.get('/api/v1/me/session-reports', {++ json: { ...report({ visibility: 'private' }), owner: OTHER, summary: 'Mine, with a body owner that must lose.' },++ headers: { 'x-test-user': 'owner', 'sec-fetch-site': 'same-origin' },++ });++ assert.strictEqual(r.status, 201, r.text);++ const stored = await t.ctx.sessionReports.get(r.json().id);++ assert.strictEqual(stored.owner, OWNER, 'the body owner never wins');++ assert.strictEqual(stored.visibility, 'private', 'private by default');++ });++++ await check('signed out is a 401, and the own list is only that person\'s', async () => {++ const anon = await t.get('/api/v1/me/session-reports', { json: report() });++ assert.strictEqual(anon.status, 401);++ await t.get('/internal/session-reports', { json: report({ owner: OTHER, visibility: 'public' }), headers: { authorization: 'Bearer good' } });++ const mine = await t.get('/api/v1/me/session-reports?days=7', { headers: { 'x-test-user': 'owner' } });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.ok(mine.json().reports.every((r) => r.owner === OWNER), 'never another person\'s report');++ assert.strictEqual(mine.headers.get('cache-control'), 'private, no-store');++ });++++ await check('a public report cites a topic by its whole name; a private one is never a citation', async () => {++ const wrote = await t.get('/internal/session-reports', {++ json: report({ task: { ref: 'tsk_login_page_1', title: 'Fix the login page' }, summary: 'The login page redirects again.', visibility: 'public' }),++ headers: { authorization: 'Bearer good' },++ });++ assert.strictEqual(wrote.status, 201, wrote.text);++ await t.get('/internal/session-reports', {++ json: report({ task: { ref: 'tsk_private_1', title: 'Fix the login page privately' }, visibility: 'private' }),++ headers: { authorization: 'Bearer good' },++ });++ const matches = await t.ctx.sessionReports.listPublicMatchingTopic('Login page');++ assert.ok(matches.length >= 1, 'the public report is cited');++ assert.ok(matches.every((r) => /login page/i.test(r.task.title + ' ' + r.summary)), 'every citation speaks of the topic');++ assert.ok(!matches.some((r) => /privately/.test(r.task.title)), 'a private report is never cited');++ assert.deepStrictEqual(await t.ctx.sessionReports.listPublicMatchingTopic('login pag'), [], 'a partial name is not the topic');++ assert.deepStrictEqual(await t.ctx.sessionReports.listPublicMatchingTopic('ogin'), [], 'a name inside a word is not the topic');++ });++++ await check('the topic page draws the citations and the episode draws its chapters into the recording', async () => {++ const topic = views.topic({ topic: { topic: { title: 'Login page', kind: 'tech' }, takes: [] }, citations: [{ id: 'rps_01JAN2C3D4E5F6G7H8J9K0MNPS', task: { title: 'Fix the login page' }, summary: 'Done', outcome: 'done' }] });++ assert.match(topic, /Agents that worked on this/);++ assert.match(topic, /Fix the login page/);++ const episode = views.episode({ episode: { episode: { title: 'An episode', vod_id: 4242, chapters: [{ sec: 95, title: 'The login bug' }, { sec: 600, title: 'Tests pass' }] }, stream: {} } });++ assert.match(episode, /https:\/\/openvibe\.video\/v\/4242\?t=95/);++ assert.match(episode, /01:35/);++ const noVod = views.episode({ episode: { episode: { title: 'No recording', chapters: [{ sec: 5, title: 'A chapter' }] }, stream: {} } });++ assert.ok(!/openvibe\.video/.test(noVod), 'no recording: a chapter is text, never a broken link');++ });++++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e.stack || String(e)); process.exit(1); });
@@ -251,6 +251,29 @@ async function createApp(opts = {}) {
@@ -251,6 +251,29 @@ async function createApp(opts = {}) {
// ── 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 }));
+ // ── A session report from any harness with the person's own token (Agent Streams S7) ──
+ // openvibe-sdk/reports writes here. The Report service takes the owner from the token, never from the body; the
+ // report is private by default; a browser call must be same-origin, while a server-to-server call carries a Bearer.
+ app.post('/api/v1/me/session-reports', express.json({ limit: '256kb' }), async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ const viewer = sharing.viewerOf(req.user);
+ if (!viewer) return contracts.http.sendProblem(res, 401, 'auth.sign_in_required', { detail: 'Sign in to write your report.', ctx: req.ov });
+ const bearer = /^Bearer\s+\S+/i.test(String(req.headers.authorization || ''));
+ const sameSite = ['same-origin', 'same-site'].includes(String(req.headers['sec-fetch-site'] || ''));
+ if (!bearer && !sameSite && !req.headers['x-requested-with']) {
+ return contracts.http.sendProblem(res, 403, 'share.csrf', { detail: 'Cross-site request refused', ctx: req.ov });
+ }
+ if (!sessionReports) return contracts.http.sendProblem(res, 503, 'report.no_database', { detail: 'Report has no database in this process.', ctx: req.ov });
+ const body = req.body && typeof req.body === 'object' ? { ...req.body } : {};
+ delete body.owner;
+ try {
+ const out = await sessionReports.write({ ...body, owner: viewer.subject.id });
+ return res.status(out.created ? 201 : 200).json(out);
+ } catch (err) {
+ return contracts.http.sendProblem(res, 400, 'report.invalid', { detail: String((err && err.message) || err).slice(0, 300), ctx: req.ov });
+ }
+ });
+
// ── API: CORS-open, public for two minutes, 120 requests a minute per address ──
// ── API: CORS-open, public for two minutes, 120 requests a minute per address ──
app.use('/api/v1', (_req, res, next) => {
app.use('/api/v1', (_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Origin', '*');
@@ -124,6 +124,19 @@ function createApi({ network, live, analytics = null, aiUsage = null, aiExchange
@@ -124,6 +124,19 @@ function createApi({ network, live, analytics = null, aiUsage = null, aiExchange
return res.json(recaps);
return res.json(recaps);
});
});
+ // 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) => {
+ if (!sessionReports) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');
+ const viewer = sharing.viewerOf(req.user);
+ if (!viewer) return problem(req, res, 401, 'auth.sign_in_required', 'Sign in to read your agents.');
+ const days = Math.min(30, Math.max(1, Number(req.query.days) || 7));
+ const from = now() - days * 24 * 60 * 60_000;
+ const reports = (await sessionReports.listForOwner(viewer.subject.id, { limit: 200 }))
+ .filter((r) => { const at = Date.parse(r.created_at); return !Number.isFinite(at) || at >= from; });
+ return res.json({ days, reports });
+ });
+
// ── Session reports (Agent Streams S3), in the sharing system's four audiences ─────────────────────
// ── Session reports (Agent Streams S3), in the sharing system's four audiences ─────────────────────
//
//
// The share row decides through canView, exactly as the page does: a report of Specific people is a 404 for a
// The share row decides through canView, exactly as the page does: a report of Specific people is a 404 for a
@@ -186,7 +186,9 @@ function createPageRoutes(ctx) {
@@ -186,7 +186,9 @@ function createPageRoutes(ctx) {
if (!isSlug(slug)) return notFound(req, res);
if (!isSlug(slug)) return notFound(req, res);
const topic = await live.topic(slug);
const topic = await live.topic(slug);
if (!topic) return notFound(req, res);
if (!topic) return notFound(req, res);
- return send(req, res, views.topic({ topic }));
+ // S7: a public agent report may cite the topic when the agent worked on it (only Everyone's reports).
+ const citations = sessionReports ? await sessionReports.listPublicMatchingTopic((topic.topic && topic.topic.title) || topic.title).catch(() => []) : [];
+ return send(req, res, views.topic({ topic, citations }));
});
});
r.get('/rundown/e/:streamId', async (req, res) => {
r.get('/rundown/e/:streamId', async (req, res) => {
@@ -306,6 +306,23 @@ function createSessionReports({ store, config, log = console, sharing = null })
@@ -306,6 +306,23 @@ function createSessionReports({ store, config, log = console, sharing = null })
return all.filter((r) => String(r.created_at || '').slice(0, 10) === day).slice(0, limit);
return all.filter((r) => String(r.created_at || '').slice(0, 10) === day).slice(0, limit);
}
}
+ /**
+ * The public reports whose task says the agent worked on this topic (S7). Only Everyone's reports are read
+ * (listPublic is the sharing system's public answer), and the topic's whole name must appear as a whole phrase:
+ * "login" is not "login page", and a name inside another word is never a match.
+ */
+ async function listPublicMatchingTopic(title, { limit = 6 } = {}) {
+ const phrase = normalizeTopic(title);
+ if (!phrase) return [];
+ const all = await listPublic({ limit: 500 });
+ const re = new RegExp(`(^| )${phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}( |$)`);
+ return all.filter((r) => {
+ const text = [r.task && r.task.title, r.summary, ...(Array.isArray(r.highlights) ? r.highlights : [])]
+ .map(normalizeTopic).filter(Boolean).join(' ');
+ return re.test(text);
+ }).slice(0, Math.max(1, Math.min(20, Number(limit) || 6)));
+ }
+
/** A person's own reports, newest first (their page and their default's "apply to all"). */
/** A person's own reports, newest first (their page and their default's "apply to all"). */
async function listForOwner(owner, { limit = 24 } = {}) {
async function listForOwner(owner, { limit = 24 } = {}) {
if (!db || !owner) return [];
if (!db || !owner) return [];
@@ -337,8 +354,13 @@ function createSessionReports({ store, config, log = console, sharing = null })
@@ -337,8 +354,13 @@ function createSessionReports({ store, config, log = console, sharing = null })
return {
return {
write, writeStream, get, getWithAudience, listPublic, listPublicToday, listForOwner, attachStream, urlFor,
write, writeStream, get, getWithAudience, listPublic, listPublicToday, listForOwner, attachStream, urlFor,
- defaultAudience, setDefaultAudience, startingAudience,
+ defaultAudience, setDefaultAudience, startingAudience, listPublicMatchingTopic,
};
};
}
}
-module.exports = { createSessionReports, cleanBody, scrub, scrubDeep, rowToReport, ID_RE };
\ No newline at end of file
\ No newline at end of file
+/** A topic or a report's text as words: lower case, accents folded, punctuation and markup gone. */
+function normalizeTopic(value) {
+ return String(value || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, ' ').trim();
+}
+
+module.exports = { createSessionReports, cleanBody, scrub, scrubDeep, rowToReport, normalizeTopic, ID_RE };
\ No newline at end of file
\ No newline at end of file
@@ -450,21 +450,43 @@ function sharingChip(audience, members = []) {
@@ -450,21 +450,43 @@ function sharingChip(audience, members = []) {
return 'Only you';
return 'Only you';
}
}
-function topic({ topic: d } = {}) {
+function topic({ topic: d, citations = [] } = {}) {
const t = d.topic || {};
const t = d.topic || {};
const takes = (d.takes || []).slice(0, 40);
const takes = (d.takes || []).slice(0, 40);
+ // S7: a public agent report whose task worked on this topic, under the whole-name rule. Only Everyone's reports
+ // reach here (the page asks listPublicMatchingTopic); a hidden report is never a citation.
+ const cites = (Array.isArray(citations) ? citations : []).slice(0, 6);
+ const citesHTML = cites.length ? `<section aria-labelledby="rp-cite-h"><h2 id="rp-cite-h">Agents that worked on this</h2><ul class="rp-topics">${cites.map((r) => `<li class="rp-report"><a href="/s/${encodeURIComponent(r.id)}"><span class="rp-kind">${esc((r.outcome || 'done') === 'failed' ? 'Failed' : (r.outcome || 'done') === 'stopped' ? 'Stopped' : 'Done')}</span><b>${esc((r.task && r.task.title) || 'An agent session')}</b><span class="rp-report-s">${esc(r.summary || '')}</span><span class="rp-meta">${AI}${r.worked_seconds ? ` · ${esc(worked(r))}` : ''}</span></a></li>`).join('')}</ul></section>` : '';
const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · ${esc(KIND[t.kind] || t.kind || 'Topic')}</p><h1>${esc(t.title)}</h1>
const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · ${esc(KIND[t.kind] || t.kind || 'Topic')}</p><h1>${esc(t.title)}</h1>
${t.headline ? `<p class="rp-lede">${esc(t.headline)}</p>` : ''}${t.summary ? `<p class="rp-summary">${esc(t.summary)}</p>` : ''}<p class="rp-meta">${AI}${t.first_seen_at ? ` · since ${esc(day(t.first_seen_at))}` : ''}</p></header>
${t.headline ? `<p class="rp-lede">${esc(t.headline)}</p>` : ''}${t.summary ? `<p class="rp-summary">${esc(t.summary)}</p>` : ''}<p class="rp-meta">${AI}${t.first_seen_at ? ` · since ${esc(day(t.first_seen_at))}` : ''}</p></header>
${takes.length ? `<section aria-labelledby="rp-tk-h"><h2 id="rp-tk-h">Takes</h2><ol class="rp-takes">${takes.map(takeRow).join('')}</ol></section>` : ''}
${takes.length ? `<section aria-labelledby="rp-tk-h"><h2 id="rp-tk-h">Takes</h2><ol class="rp-takes">${takes.map(takeRow).join('')}</ol></section>` : ''}
+${citesHTML}
${(d.related || []).length ? `<section aria-labelledby="rp-rel-h"><h2 id="rp-rel-h">Related</h2><ul class="rp-chips">${d.related.slice(0, 12).map((r) => `<li><a href="/rundown/t/${encodeURIComponent(r.slug)}">${esc(r.title)}</a></li>`).join('')}</ul></section>` : ''}`;
${(d.related || []).length ? `<section aria-labelledby="rp-rel-h"><h2 id="rp-rel-h">Related</h2><ul class="rp-chips">${d.related.slice(0, 12).map((r) => `<li><a href="/rundown/t/${encodeURIComponent(r.slug)}">${esc(r.title)}</a></li>`).join('')}</ul></section>` : ''}`;
return layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}` });
return layout({ title: `${t.title}: The Rundown`, description: t.headline || `What streamers said about ${t.title}.`, body, active: '/rundown', path: `/rundown/t/${encodeURIComponent(t.slug || '')}` });
}
}
-function episode({ episode: d } = {}) {
+/** A chapter's moment in the recording on OpenVibe.Video (the same link Live's own Rundown draws). */
+function chapterHref(vodId, sec) {
+ const id = String(vodId == null ? '' : vodId).trim();
+ if (!/^\d{1,16}$/.test(id)) return null;
+ const t = Math.max(0, Math.floor(Number(sec) || 0));
+ return `https://openvibe.video/v/${encodeURIComponent(id)}${t ? `?t=${t}` : ''}`;
+}
+
+function episode({ episode: d, citations = [] } = {}) {
const e = d.episode || {}, s = d.stream || {};
const e = d.episode || {}, s = d.stream || {};
const segs = (d.segments || []).slice(0, 60);
const segs = (d.segments || []).slice(0, 60);
+ // S7: the chapters are the recording's moments, linked into OpenVibe.Video; a chapter with no recording stays text.
+ const vod = e.vod_id || s.vod_id || null;
+ const chapters = (Array.isArray(e.chapters) ? e.chapters : []).slice(0, 40);
+ const chaptersHTML = chapters.length ? `<section aria-labelledby="rp-ch-h"><h2 id="rp-ch-h">Chapters</h2><ol class="rp-chapters">${chapters.map((c) => {
+ const href = vod ? chapterHref(vod, c.sec) : null;
+ const stamp = `<span class="rp-stamp">${esc(String(Math.floor((Number(c.sec) || 0) / 60)).padStart(2, '0'))}:${esc(String((Number(c.sec) || 0) % 60).padStart(2, '0'))}</span>`;
+ return `<li>${href ? `<a href="${esc(href)}">${stamp} ${esc(c.title)}</a>` : `${stamp} ${esc(c.title)}`}</li>`;
+ }).join('')}</ol></section>` : '';
const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · episode</p><h1>${esc(e.title || s.title || 'Episode')}</h1>${e.logline ? `<p class="rp-lede">${esc(e.logline)}</p>` : ''}
const body = `<header class="rp-page-h"><p class="rp-kicker"><a href="/rundown">The Rundown</a> · episode</p><h1>${esc(e.title || s.title || 'Episode')}</h1>${e.logline ? `<p class="rp-lede">${esc(e.logline)}</p>` : ''}
<p class="rp-meta">${AI}${who(s.user) ? ` · <a href="/rundown/s/${encodeURIComponent(s.user.username)}">${esc(who(s.user))}</a>` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${s.id ? ` · <a href="/after-show/${encodeURIComponent(s.id)}">After-show report</a>` : ''}</p></header>
<p class="rp-meta">${AI}${who(s.user) ? ` · <a href="/rundown/s/${encodeURIComponent(s.user.username)}">${esc(who(s.user))}</a>` : ''}${s.started_at ? ` · ${esc(dayTime(s.started_at))}` : ''}${s.id ? ` · <a href="/after-show/${encodeURIComponent(s.id)}">After-show report</a>` : ''}</p></header>
+${chaptersHTML}
${segs.length ? `<section aria-labelledby="rp-sg-h"><h2 id="rp-sg-h">What happened</h2><ol class="rp-takes rp-timeline">${segs.map(takeRow).join('')}</ol></section>` : ''}
${segs.length ? `<section aria-labelledby="rp-sg-h"><h2 id="rp-sg-h">What happened</h2><ol class="rp-takes rp-timeline">${segs.map(takeRow).join('')}</ol></section>` : ''}
${(d.more || []).length ? `<section aria-labelledby="rp-mo-h"><h2 id="rp-mo-h">More from ${esc(who(s.user) || 'this streamer')}</h2><ul class="rp-chips">${d.more.slice(0, 8).map((m) => `<li><a href="/rundown/e/${encodeURIComponent(m.stream_id)}">${esc(m.title)}</a></li>`).join('')}</ul></section>` : ''}`;
${(d.more || []).length ? `<section aria-labelledby="rp-mo-h"><h2 id="rp-mo-h">More from ${esc(who(s.user) || 'this streamer')}</h2><ul class="rp-chips">${d.more.slice(0, 8).map((m) => `<li><a href="/rundown/e/${encodeURIComponent(m.stream_id)}">${esc(m.title)}</a></li>`).join('')}</ul></section>` : ''}`;
return layout({ title: `${e.title || s.title}: The Rundown`, description: e.logline || '', body, active: '/rundown', path: `/rundown/e/${encodeURIComponent(s.id || e.stream_id || '')}` });
return layout({ title: `${e.title || s.title}: The Rundown`, description: e.logline || '', body, active: '/rundown', path: `/rundown/e/${encodeURIComponent(s.id || e.stream_id || '')}` });
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+'use strict';
+/**
+ * Report, S7: chapter links and topic citations, and the third-party report door.
+ *
+ * - an episode's chapters link into OpenVibe.Video at their second, from the episode's own vod_id;
+ * - a topic page cites a public agent report when the agent worked on the topic's whole name, never a hidden report
+ * and never a partial name;
+ * - POST /api/v1/me/session-reports writes with the person's token: the owner comes from the token, never the body,
+ * and the report is private by default; GET /api/v1/me/session-reports is only that person's.
+ */
+const assert = require('assert');
+const express = require('express');
+const { boot, check, done } = require('./helpers/boot');
+const views = require('../server/views');
+
+const OWNER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPS';
+const OTHER = 'usr_01JAN2C3D4E5F6G7H8J9K0MNPT';
+
+const sso = {
+ router: () => express.Router(),
+ optionalAuth: () => (req, _res, next) => {
+ if (req.headers['x-test-user'] === 'owner') req.user = { subject_id: OWNER, role: 'user', username: 'ann' };
+ next();
+ },
+};
+const verifyServiceToken = async (token) => (token === 'good'
+ ? { ok: true, claims: { sub: 'svc:codes', cap: ['report.session.write'], aud: ['openvibe.report'] } }
+ : { ok: false, code: 'token.invalid' });
+const report = (over = {}) => ({
+ owner: OWNER, source: 'codes', task: { ref: 'tsk_' + Math.random().toString(36).slice(2), title: 'Fix the checkout page' },
+ summary: 'The agent fixed the checkout page and ran the tests.', visibility: 'public', ...over,
+});
+
+(async () => {
+ const t = await boot({ db: true, sso, verifyServiceToken, now: () => Date.parse('2026-11-07T12:00:00Z') });
+ try {
+ await check('the third-party door takes the owner from the token, not the body', async () => {
+ const r = await t.get('/api/v1/me/session-reports', {
+ json: { ...report({ visibility: 'private' }), owner: OTHER, summary: 'Mine, with a body owner that must lose.' },
+ headers: { 'x-test-user': 'owner', 'sec-fetch-site': 'same-origin' },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const stored = await t.ctx.sessionReports.get(r.json().id);
+ assert.strictEqual(stored.owner, OWNER, 'the body owner never wins');
+ assert.strictEqual(stored.visibility, 'private', 'private by default');
+ });
+
+ await check('signed out is a 401, and the own list is only that person\'s', async () => {
+ const anon = await t.get('/api/v1/me/session-reports', { json: report() });
+ assert.strictEqual(anon.status, 401);
+ await t.get('/internal/session-reports', { json: report({ owner: OTHER, visibility: 'public' }), headers: { authorization: 'Bearer good' } });
+ const mine = await t.get('/api/v1/me/session-reports?days=7', { headers: { 'x-test-user': 'owner' } });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.ok(mine.json().reports.every((r) => r.owner === OWNER), 'never another person\'s report');
+ assert.strictEqual(mine.headers.get('cache-control'), 'private, no-store');
+ });
+
+ await check('a public report cites a topic by its whole name; a private one is never a citation', async () => {
+ const wrote = await t.get('/internal/session-reports', {
+ json: report({ task: { ref: 'tsk_login_page_1', title: 'Fix the login page' }, summary: 'The login page redirects again.', visibility: 'public' }),
+ headers: { authorization: 'Bearer good' },
+ });
+ assert.strictEqual(wrote.status, 201, wrote.text);
+ await t.get('/internal/session-reports', {
+ json: report({ task: { ref: 'tsk_private_1', title: 'Fix the login page privately' }, visibility: 'private' }),
+ headers: { authorization: 'Bearer good' },
+ });
+ const matches = await t.ctx.sessionReports.listPublicMatchingTopic('Login page');
+ assert.ok(matches.length >= 1, 'the public report is cited');
+ assert.ok(matches.every((r) => /login page/i.test(r.task.title + ' ' + r.summary)), 'every citation speaks of the topic');
+ assert.ok(!matches.some((r) => /privately/.test(r.task.title)), 'a private report is never cited');
+ assert.deepStrictEqual(await t.ctx.sessionReports.listPublicMatchingTopic('login pag'), [], 'a partial name is not the topic');
+ assert.deepStrictEqual(await t.ctx.sessionReports.listPublicMatchingTopic('ogin'), [], 'a name inside a word is not the topic');
+ });
+
+ await check('the topic page draws the citations and the episode draws its chapters into the recording', async () => {
+ const topic = views.topic({ topic: { topic: { title: 'Login page', kind: 'tech' }, takes: [] }, citations: [{ id: 'rps_01JAN2C3D4E5F6G7H8J9K0MNPS', task: { title: 'Fix the login page' }, summary: 'Done', outcome: 'done' }] });
+ assert.match(topic, /Agents that worked on this/);
+ assert.match(topic, /Fix the login page/);
+ const episode = views.episode({ episode: { episode: { title: 'An episode', vod_id: 4242, chapters: [{ sec: 95, title: 'The login bug' }, { sec: 600, title: 'Tests pass' }] }, stream: {} } });
+ assert.match(episode, /https:\/\/openvibe\.video\/v\/4242\?t=95/);
+ assert.match(episode, /01:35/);
+ const noVod = views.episode({ episode: { episode: { title: 'No recording', chapters: [{ sec: 5, title: 'A chapter' }] }, stream: {} } });
+ assert.ok(!/openvibe\.video/.test(noVod), 'no recording: a chapter is text, never a broken link');
+ });
+
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e.stack || String(e)); process.exit(1); });