openvibers/openvibe.report Commits

Session reports: the owner chooses who sees it (#110)

OP OpenVibers committed 6 days ago · e98a1c8113 · 15 files, +1303 −40

* Session reports: the owner chooses who sees it

A session report now has the sharing system's four audiences (Everyone,
Anyone with the link, Specific people, Only me), set from the one Share
sheet on the report page. The share row (openvibe-sdk/share, kind
'session-report') is the access authority: the page, the API, the lists,
the sitemap and the search feed all ask canView, so a report that leaves
Everyone drops out of every list at once and sends its search tombstone
in the same transaction. The old visibility column is kept and still
written for N-1 (private -> private, unlisted -> link, public -> public).

- migration 0008 (expand only): the share tables and the owner's default
- server/sharing.js: register on write, canView on every read, describe
  for the sheet, and the SDK's router mounted at /api/v1
- /you: "New reports: <audience>" (Only me until the owner changes it)
  and the owner's own reports with their audiences
- scripts/backfill-session-shares.js: the mapping for existing reports,
  a dry run by default, --apply to write, idempotent, never overrules an
  audience the owner chose
- the after-show cache: 60 s, only answers Live marked Cache-Control
  public, a 404 is a miss that is re-asked, and no stale copy is served

* A public report is cached for a signed-out reader only

The page handed `public, max-age=120` to every reader of an Everyone
report. The owner's copy names the Share sheet's kind and id and who else
may open it, and Cloudflare on our zones ignores Vary and Authorization,
so a shared cache could store the owner's page and serve it to the next
visitor: the member list leaked.

Now the public header is only for a signed-out reader's copy; any signed-in
reader gets private, no-store. The same rule on the API: the session-report
routes set their own Cache-Control (signed in private, no-store; signed out
the public one) instead of inheriting the router's public default.

/you and the API's owner list were already private, no-store, and the
Rundown's cards carry only public reports, so both are unchanged.

The test now checks the header for each of the four readers on the page and
the API, and adds a check that the owner's copy is private and the
signed-out copy is the cacheable one, with no access sheet, no report id
as a sheet target, no audience line and no member id in it.

Changes

ddiff --git a/migrations/0008_session_report_sharing.sql b/migrations/0008_session_report_sharing.sql
nnew file mode 100644
iindex 0000000..4831c9d
---- /dev/null
++++ b/migrations/0008_session_report_sharing.sql
@@@ -0,0 +1,96 @@
++-- phase: expand
++-- Session reports gain the four audiences (design 2026-11-06, plan-state-2026-10-22/VISIBILITY-EVERYWHERE-DESIGN-2026-11-06.md,
++-- rows "Report: session reports (S3)" and "Report: after-show reports"; SHARING-DESIGN-2026-11-06.md).
++--
++-- The owner of a report chooses who sees it: Everyone, Anyone with the link, Specific people or Only me. Those are the
++-- sharing system's own audiences (openvibe-sdk/share, share.v1), so a report's setting lives in a share row of kind
++-- 'session-report' (server/sharing.js) and the Share sheet is the only place it is set.
++--
++-- report_sessions.visibility (private | unlisted | public) stays, is still written for N-1 and maps 1:1:
++-- private -> private (Only me), unlisted -> link (Anyone with the link), public -> public (Everyone).
++-- scripts/backfill-session-shares.js fills the share row of every existing report (dry run by default, --apply to write).
++--
++-- What else is here: the sharing system's own expand-only tables, kept in this migration because a table is only ever
++-- created by a migration (the SDK owns their shape; service 'report', kind 'session-report'), and the owner's default
++-- audience for new reports, which is Only me until they change it.
++--
++-- Expand only: nothing that exists is changed or dropped, and an older release reads visibility exactly as before.
++
++
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ lock text NOT NULL DEFAULT 'none',
++ reseal_nonce_base text,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ key_slot jsonb,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ key_slot jsonb,
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
++-- slot a transfer was offered with. Repeating this migration is a no-op.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
++DO $$ BEGIN
++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
++
++-- The owner's default for the reports they make from now on. No row means Only me (the S3 default); the person
++-- changes it on their own page, and each report's sheet can still change just that one.
++CREATE TABLE IF NOT EXISTS report_session_defaults (
++ owner_subject text COLLATE "C" PRIMARY KEY,
++ audience text COLLATE "C" NOT NULL DEFAULT 'private' CHECK (audience IN ('public','link','people','private')),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
++
++-- The backfill's own ledger is not needed: a report already has its share row, which is what makes the script
++-- idempotent. This index is what it reads in one pass, and what every page's audience lookup uses.
++CREATE INDEX IF NOT EXISTS shares_report_sessions ON shares (kind, resource_id) WHERE service = 'report';
ddiff --git a/scripts/backfill-session-shares.js b/scripts/backfill-session-shares.js
nnew file mode 100644
iindex 0000000..a242a54
---- /dev/null
++++ b/scripts/backfill-session-shares.js
@@@ -0,0 +1,121 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Give every session report written before the sharing system a share row, from the visibility column it already has.
++ *
++ * The mapping is the design's own (VISIBILITY-EVERYWHERE-DESIGN-2026-11-06, "Report: session reports"): a report that
++ * was private is Only me, unlisted is Anyone with the link, public is Everyone. Nothing else changes: the owner stays
++ * the owner, the column is left exactly as it is (it is still written for N-1), and a report that already has a share
++ * row is skipped, whatever its audience — the owner may have chosen something else in the sheet, and this script never
++ * overrules that.
++ *
++ * Idempotent: a second run registers nothing. It prints what it would do and writes nothing unless --apply is given:
++ *
++ * node scripts/backfill-session-shares.js a dry run, the plan only
++ * node scripts/backfill-session-shares.js --apply register the rows
++ * node scripts/backfill-session-shares.js --limit 500 --apply the first 500 at a time
++ *
++ * It needs the service's own environment (DATABASE_URL, DATABASE_DIRECT_URL) and runs where the database is, as the
++ * service's role: on the host, by the operator. It never creates a table — migration 0008 already made them.
++ */
++const argv = process.argv.slice(2);
++const opt = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : null; };
++const APPLY = argv.includes('--apply');
++const LIMIT = Math.max(1, Math.min(100000, Number(opt('--limit')) || 100000));
++
++/** The design's mapping, written out here so the script states it rather than importing a rule it could drift from. */
++const FROM_VISIBILITY = { private: 'private', unlisted: 'link', public: 'public' };
++const LABEL = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };
++const ORDER = ['public', 'link', 'people', 'private'];
++
++/** The rows that still need a share row: every report without one, oldest id first, at most `limit` of them. */
++async function plan(db, { limit = LIMIT } = {}) {
++ const rows = await db.prepare(
++ `SELECT r.id, r.owner, r.visibility FROM report_sessions r
++ LEFT JOIN shares s ON s.service = 'report' AND s.kind = 'session-report' AND s.resource_id = r.id
++ WHERE s.resource_id IS NULL
++ ORDER BY r.id ASC LIMIT ?`).all(Math.max(1, Number(limit) || LIMIT));
++ return rows.map((row) => ({
++ id: row.id,
++ owner: row.owner,
++ from: row.visibility,
++ to: FROM_VISIBILITY[row.visibility] || 'private',
++ }));
++}
++
++/**
++ * The plan as lines of text (the same in a dry run and a real one, so the two can be compared) and the counts.
++ */
++function describe(rows) {
++ const counts = rows.reduce((a, p) => ({ ...a, [p.to]: (a[p.to] || 0) + 1 }), {});
++ const lines = [];
++ for (const audience of ORDER) {
++ if (counts[audience]) lines.push(` ${String(counts[audience]).padStart(6)} ${LABEL[audience]} (from ${audience === 'link' ? 'unlisted' : audience})`);
++ }
++ if (rows.length) {
++ lines.push('The first few:');
++ for (const p of rows.slice(0, 10)) lines.push(` ${p.id} ${p.from} -> ${p.to}`);
++ if (rows.length > 10) lines.push(` ... and ${rows.length - 10} more`);
++ }
++ return { lines, counts };
++}
++
++/**
++ * Run the plan. `register(id, owner, oldVisibility)` is server/sharing's own register, so a backfilled report is
++ * exactly one a fresh write would have made. Answers { planned, made, failed }: a false from register means another
++ * run got there first (409 share.exists), which is not a failure unless no row exists afterwards.
++ */
++async function backfill({ db, register, apply = false, limit = LIMIT }) {
++ const rows = await plan(db, { limit });
++ if (!apply) return { planned: rows.length, made: 0, failed: 0, rows };
++ let made = 0;
++ let failed = 0;
++ for (const p of rows) {
++ if (await register(p.id, p.owner, p.from)) { made++; continue; }
++ const there = await db.prepare('SELECT 1 AS one FROM shares WHERE service = ? AND kind = ? AND resource_id = ?')
++ .get('report', 'session-report', p.id);
++ if (!there) failed++;
++ }
++ return { planned: rows.length, made, failed, rows };
++}
++
++async function main() {
++ const configLib = require('../server/config');
++ const { openStore } = require('../server/db');
++ const sharing = require('../server/sharing');
++
++ const config = configLib.load();
++ if (!config.db || !config.db.url) {
++ console.error('No DATABASE_URL: run this with the service environment (OpenVibe.Host roles/data), never against nothing.');
++ return 2;
++ }
++ const store = await openStore(config, { log: { warn() {}, log() {}, error() {}, info() {} } });
++ try {
++ if (!sharing.init({ store })) {
++ console.error('The sharing system is not available in this checkout (openvibe-sdk/share missing): nothing was done.');
++ return 3;
++ }
++ const rows = await plan(store.db, { limit: LIMIT });
++ const { lines } = describe(rows);
++ console.log(`${APPLY ? 'Backfilling' : 'Would backfill'} ${rows.length} session report(s) with no share row:`);
++ for (const line of lines) console.log(line);
++ if (!APPLY) {
++ console.log('A dry run: nothing was written. Run again with --apply to register these rows.');
++ return 0;
++ }
++ const done = await backfill({ db: store.db, register: sharing.register, apply: true, limit: LIMIT });
++ console.log(`Registered ${done.made} share row(s)${done.failed ? `, ${done.failed} failed (run it again; the script is idempotent)` : ''}.`);
++ return done.failed ? 1 : 0;
++ } finally {
++ await store.close();
++ }
++}
++
++if (require.main === module) {
++ main().then((code) => process.exit(code)).catch((err) => {
++ console.error(`[backfill-session-shares] ${(err && err.message) || err}`);
++ process.exit(1);
++ });
++}
++
++module.exports = { main, plan, describe, backfill, FROM_VISIBILITY, LABEL };
ddiff --git a/server/app.js b/server/app.js
iindex 602ccf5..7995bbc 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -38,6 +38,7 @@ const createAiExchangeConfig = require('./ai-exchange-config').create;
const { createSearchIndex } = require('./search-index');
const { createSessionReports } = require('./session-reports');
const { createSessionReportRoutes } = require('./http/session-reports');
++const sharing = require('./sharing');
const { createShadow } = require('./analytics/shadow');
const { createApi } = require('./http/api');
const { createPageRoutes, createPublicPages } = require('./http/pages');
@@@ -51,6 +52,29 @@ const { PUBLIC_DIR, assetVersion } = require('./assets');
const VERSION = require('../package.json').version;
const PAGE_CACHE = 'public, max-age=120';
++/**
++ * The Share sheet's access part (openvibe-sdk/share's router), built on the first request rather than at load (the
++ * database opens at boot). It sees only /api/v1/<kind>/<id>/share, /api/v1/shared-with-me and /api/v1/share/people;
++ * anything else falls through to Report's own read API.
++ *
++ * The caller is the signed-in person (Report's own sso session, cookie or bearer). `describe` names a report for the
++ * sheet, and the share store itself refuses anyone who is not its owner: a 404 that says nothing about the report.
++ */
++function shareRouterFor({ log }) {
++ let built;
++ return function shareRouter(req, res, next) {
++ if (!req.path.startsWith('/api/v1/')) return next();
++ if (built === undefined) {
++ built = sharing.router({
++ authenticate: async (request) => sharing.viewerOf(request.user),
++ describe: (ref) => sharing.describe(ref),
++ }) || null;
++ }
++ if (!built) return next();
++ return built(req, res, next);
++ };
++}
++
/** opts: config, store, fetchImpl, log, now, network, live, billing, analytics, shadow, sso, releaseEnv */
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
@@@ -76,8 +100,13 @@ async function createApp(opts = {}) {
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
// The report of every agent session (Agent Streams S3): one table, one route, one page.
const sessionReports = opts.sessionReports || (store ? createSessionReports({ store, config, log }) : null);
++ // The sharing system (openvibe-sdk/share, share.v1): a report's four audiences live in its share row, the owner's
++ // Share sheet changes it, and every read path asks canView (server/sharing.js). Built before the session-report
++ // writer, so the first write registers its row.
++ if (store) sharing.init({ store, network: opts.shareNetwork || null, events: opts.shareEvents || null, notify: opts.shareNotify || null });
// The Rundown topic pages in OpenVibe.Search (started by server/index.js, never under REPORT_DRILL).
const searchIndex = opts.searchIndex || (store ? createSearchIndex({ config, store, live, sessionReports, log }) : null);
++ if (searchIndex) require('./search-index').setInstance(searchIndex);
views.configure({ baseUrl: config.baseUrl });
// A person's sign-in (the `report` client, the same id and secret Report already reads Billing with).
@@@ -188,6 +217,34 @@ async function createApp(opts = {}) {
app.use('/auth', sso.router(express));
app.use(sso.optionalAuth());
++ // ── The sharing system's HTTP surface (the Share sheet's access part), at /api/v1/<kind>/<id>/share ──
++ // Mounted after sign-in, before the read API, so its paths are never read as one of Report's. Absent when the
++ // pinned openvibe-sdk predates `share`: then only the old column's three levels work, and a report nobody may open
++ // is a 404 — fail closed.
++ app.use(shareRouterFor({ log }));
++ // The owner's default for the reports they make from now on (their own page, /you). Signed in only, same-origin
++ // only, and it changes nothing about a report that already exists — the sheet changes those one by one.
++ app.post('/api/v1/me/session-reports/default', express.json({ limit: '2kb' }), async (req, res) => {
++ 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 change your default.', ctx: req.ov });
++ if (!req.headers['x-requested-with'] && !['same-origin', 'same-site'].includes(String(req.headers['sec-fetch-site'] || ''))) {
++ return contracts.http.sendProblem(res, 403, 'share.csrf', { detail: 'Cross-site request refused', ctx: req.ov });
++ }
++ const audience = String((req.body && req.body.audience) || '');
++ if (!sharing.AUDIENCES.includes(audience)) {
++ return contracts.http.sendProblem(res, 422, 'ai.invalid', { detail: 'audience must be one of ' + sharing.AUDIENCES.join(', '), 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 });
++ try {
++ await sessionReports.setDefaultAudience(viewer.subject.id, audience);
++ return res.json({ audience, visibility: sharing.visibilityFor(audience) });
++ } catch (err) {
++ log.error('[OpenVibe.Report] the session report default failed:', err && err.message ? String(err.message).slice(0, 200) : err);
++ return contracts.http.sendProblem(res, 500, 'report.default_failed', { detail: 'Your default could not be saved.', ctx: req.ov });
++ }
++ });
++
// ── The door for the first-party services that ran an agent session (Codes, Actor, Live) ──
if (sessionReports) app.use('/internal', createSessionReportRoutes({ config, sessionReports, log, verify: opts.verifyServiceToken }));
@@@ -207,7 +264,7 @@ async function createApp(opts = {}) {
legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}));
-- app.use('/api/v1', createApi({ network, live, analytics, aiUsage, aiExchange, config, now, log }));
++ app.use('/api/v1', createApi({ network, live, analytics, aiUsage, aiExchange, sessionReports, config, now, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Crawl artifacts, then the pages ─────────────────────
ddiff --git a/server/data/live.js b/server/data/live.js
iindex 6a13264..e7bef14 100644
---- a/server/data/live.js
++++ b/server/data/live.js
@@@ -15,7 +15,7 @@
* a 404 without asking Live.
*/
const { createCache } = require('./cache');
--const { trim, requestJson, isSlug, streamIdOf, isUsername, TIMEOUT_MS } = require('./source');
++const { trim, requestJson, requestJsonMeta, isSlug, streamIdOf, isUsername, TIMEOUT_MS } = require('./source');
const SERVE_STALE_MS = 30 * 60_000;
@@@ -25,8 +25,10 @@ const TTL = {
topic: 5 * 60_000,
episode: 5 * 60_000,
speaker: 5 * 60_000,
-- recap: 10 * 60_000,
-- channelRecaps: 5 * 60_000,
++ // An after-show report follows its stream's Rundown episode: Live decides who sees it, and its streamer may make
++ // it private at any moment. Report holds one a minute at most, and never past what Live said (see sharedRecap).
++ recap: 60_000,
++ channelRecaps: 60_000,
};
/**
@@@ -82,16 +84,39 @@ function createLiveClient({ config = {}, fetchImpl = globalThis.fetch, log = con
return cached(`rundown:speaker:${key}`, `/api/rundown/s/${encodeURIComponent(username)}`, TTL.speaker, 'live speaker');
}
++ /**
++ * An after-show report, held for a minute and only when Live marked the answer public.
++ *
++ * Report shows these as Live decided them (the streamer's Rundown episode is the audience), so it must never widen
++ * one: an answer Live did not mark `public` - a per-person view, a no-store - is returned to this reader and not
++ * kept. A 404 is a miss, not an answer: the entry is emptied, so a report its streamer just made private leaves at
++ * once instead of being served from a copy for the rest of the minute, and the next read asks Live again. No stale
++ * copy is ever served here (cached() does that for the Rundown's own lists, which nobody hides): one failure is
++ * null, and the page says what it always says when Live cannot answer.
++ */
++ async function sharedRecap(key, path, ttl, label) {
++ const hit = cache.get(key);
++ if (hit !== undefined) return hit;
++ cache.delete(key);
++ const res = await requestJsonMeta(`${baseUrl}${path}`, { fetchImpl, log, timeoutMs, label });
++ if (res.status === 404) {
++ cache.delete(key);
++ return null;
++ }
++ if (!res.data || !/\bpublic\b/i.test(String(res.cacheControl || ''))) return null;
++ return cache.set(key, res.data, ttl);
++ }
++
function recap(streamId) {
const id = streamIdOf(streamId);
if (!id) return null;
-- return cached(`recap:${id}`, `/api/recap/${id}`, TTL.recap, 'live recap');
++ return sharedRecap(`recap:${id}`, `/api/recap/${id}`, TTL.recap, 'live recap');
}
function channelRecaps(username) {
if (!isUsername(username)) return null;
const key = username.toLowerCase();
-- return cached(`recaps:channel:${key}`, `/api/recap/channel/${encodeURIComponent(username)}`, TTL.channelRecaps, 'live channel recaps');
++ return sharedRecap(`recaps:channel:${key}`, `/api/recap/channel/${encodeURIComponent(username)}`, TTL.channelRecaps, 'live channel recaps');
}
return { rundown, topics, topic, episode, speaker, recap, channelRecaps, cache, baseUrl, ttlMs: TTL, settled: () => Promise.all([...refreshing.values()]) };
ddiff --git a/server/data/network-json.js b/server/data/network-json.js
nnew file mode 100644
iindex 0000000..ad61806
---- /dev/null
++++ b/server/data/network-json.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * Network's internal JSON client, with Report's service token on each call (openvibe-sdk/auth's service token client,
++ * the `report` OAuth client and its secret).
++ *
++ * The sharing system (openvibe-sdk/share) needs Network twice: to turn an @name into a person and to resolve the people
++ * it shows in the sheet. One client for both, so a test that answers Network's lookups locally changes one place
++ * (sharing._reset / the share store built with a test network).
++ */
++let override = null;
++
++/** Tests: answer Network's calls locally. */
++function _setNetwork(client) { override = client || null; }
++
++function client() {
++ if (override) return override;
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ const config = require('./config').load();
++ const base = String(config.networkInternalUrl || config.networkUrl || 'http://127.0.0.1:4000').replace(/\/+$/, '');
++ const secret = config.oauth && config.oauth.clientSecret;
++ if (!secret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Report cannot call Network');
++ const tokens = createServiceTokenClient({
++ tokenUrl: config.oauth.tokenUrl,
++ clientId: config.oauth.clientId || 'report',
++ clientSecret: secret,
++ scope: { 'openvibe.network': 'identity.read' },
++ });
++ return {
++ async json({ path, method = 'GET', query, json }) {
++ const token = await tokens.getToken({ audience: 'openvibe.network' });
++ const q = query ? `?${new URLSearchParams(query)}` : '';
++ const res = await fetch(`${base}${path}${q}`, {
++ method,
++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
++ ...(json === undefined ? {} : { body: JSON.stringify(json) }),
++ signal: AbortSignal.timeout(15_000),
++ });
++ if (!res.ok) {
++ const err = new Error(`Network ${path} answered ${res.status}`);
++ err.status = res.status;
++ throw err;
++ }
++ return await res.json();
++ },
++ };
++}
++
++module.exports = { client, _setNetwork };
ddiff --git a/server/data/source.js b/server/data/source.js
iindex e8591c2..dd42583 100644
---- a/server/data/source.js
++++ b/server/data/source.js
@@@ -58,6 +58,37 @@ async function requestJson(url, { fetchImpl = globalThis.fetch, log = console, l
}
}
++/**
++ * The same GET, but the caller also needs the answer's status and its Cache-Control: the after-show reports are only
++ * held when Live said `public`, and a 404 has to be told apart from a failure so the cache can be emptied. Answers
++ * `{ status, data, cacheControl }` with status 0 when the request never got an answer (the data is null then).
++ */
++async function requestJsonMeta(url, { fetchImpl = globalThis.fetch, log = console, label = 'source', timeoutMs = TIMEOUT_MS } = {}) {
++ let res;
++ try {
++ const signal = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
++ ? AbortSignal.timeout(timeoutMs)
++ : undefined;
++ res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });
++ } catch (err) {
++ warn(log, `${label} failed: ${reasonOf(err)}`);
++ return { status: 0, data: null, cacheControl: '' };
++ }
++
++ if (!res || !res.ok) {
++ if (!res || res.status !== 404) warn(log, `${label} failed: HTTP ${res ? res.status : 'no response'}`);
++ return { status: res ? Number(res.status) || 0 : 0, data: null, cacheControl: (res && res.headers && res.headers.get && res.headers.get('cache-control')) || '' };
++ }
++
++ const cacheControl = (res.headers && res.headers.get && res.headers.get('cache-control')) || '';
++ try {
++ return { status: res.status, data: await res.json(), cacheControl };
++ } catch (err) {
++ warn(log, `${label} failed: ${reasonOf(err)}`);
++ return { status: res.status, data: null, cacheControl };
++ }
++}
++
/** ?days=7|30|90; anything else is 30, exactly as OpenVibe.Network's own route reads it. */
function normaliseDays(value) {
const raw = Array.isArray(value) ? value[0] : value;
@@@ -78,4 +109,4 @@ function streamIdOf(value) {
const isStreamId = (value) => streamIdOf(value) !== null;
const isUsername = (value) => typeof value === 'string' && USERNAME_RE.test(value);
--module.exports = { TIMEOUT_MS, trim, warn, requestJson, normaliseDays, isSlug, streamIdOf, isStreamId, isUsername };
++module.exports = { TIMEOUT_MS, trim, warn, requestJson, requestJsonMeta, normaliseDays, isSlug, streamIdOf, isStreamId, isUsername };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex bd05dc3..7d08c50 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -26,10 +26,11 @@ const { createServiceAuth, CAPS } = require('../auth');
const { GROUPS: AI_USAGE_GROUPS, PERSON_GROUPS } = require('../analytics/ai-usage');
const GROUPS = Object.keys(AI_USAGE_GROUPS);
const designHealth = require('../data/design-health');
++const sharing = require('../sharing');
const CACHE = 'public, max-age=120';
--function createApi({ network, live, analytics = null, aiUsage = null, aiExchange = null, config = {}, now = () => Date.now(), log = console }) {
++function createApi({ network, live, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, config = {}, now = () => Date.now(), log = console }) {
const r = express.Router();
const serviceAuth = createServiceAuth({ config, now });
@@@ -43,6 +44,14 @@ function createApi({ network, live, analytics = null, aiUsage = null, aiExchange
return next();
});
++ // The session-report routes are the one place where the answer depends on who is asking, so they may not sit
++ // under the public default above: signed in is always private, no-store (the same rule the page keeps), and only
++ // a signed-out reader's copy of an Everyone report is the shared cache's.
++ r.use(['/session-reports', '/session-reports/:id'], (req, res, next) => {
++ res.set('Cache-Control', req.user ? 'private, no-store' : CACHE);
++ next();
++ });
++
// The personal routes: private and never cached, even when they refuse the caller, and their own limit.
const personalLimit = rateLimit({
windowMs: 60_000,
@@@ -115,6 +124,35 @@ function createApi({ network, live, analytics = null, aiUsage = null, aiExchange
return res.json(recaps);
});
++ // ── 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
++ // signed-in person who is not on the list, and for everyone signed out. A report that is not in Everyone is never
++ // cached in a shared cache; its answer is private and never stored.
++ r.get('/session-reports/:id', async (req, res) => {
++ if (!sessionReports) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');
++ const rep = await sessionReports.get(String(req.params.id || ''));
++ if (!rep) return notFound(req, res, 'report.session_not_found', 'No such report.');
++ const viewer = sharing.viewerOf(req.user);
++ if (!(await sharing.canView(viewer, rep))) return notFound(req, res, 'report.session_not_found', 'No such report.');
++ const audience = await sharing.audienceOf(rep);
++ if (audience !== 'public') res.set('Cache-Control', 'private, no-store');
++ return res.json({ ...rep, audience });
++ });
++
++ // The reports a reader may see: Everyone's, plus the caller's own (a list never shows a hidden item, not even as a
++ // count). The public part is what the Rundown's section and the sitemap read.
++ r.get('/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);
++ const publicOnes = await sessionReports.listPublic({ limit: 50 });
++ const mine = viewer ? (await sessionReports.listForOwner(viewer.subject.id, { limit: 50 })).filter((r) => !publicOnes.some((p) => p.id === r.id)) : [];
++ if (viewer) res.set('Cache-Control', 'private, no-store');
++ return res.json({
++ reports: [...publicOnes.map((r) => ({ ...r, audience: 'public' })), ...mine],
++ });
++ });
++
// ── The AI meter (plan L40 and L41) ─────────────────────────────────────────────
//
// The public totals: the tokens, the cache's share, the own-key share and the spend by week. No person, no
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1fd4146..6073c6d 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -25,6 +25,7 @@ const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('.
const { twinPath } = require('../agent-pages');
const designViews = require('../views/design');
const designHealth = require('../data/design-health');
++const sharing = require('../sharing');
const CACHE = 'public, max-age=120';
@@@ -160,15 +161,24 @@ function createPageRoutes(ctx) {
return send(req, res, views.rundown({ rundown, topics, reports }));
});
-- // One agent session's report (Agent Streams S3). A private report is the owner's alone, an unlisted one opens
-- // for anyone with the link, a public one opens for everyone.
++ // One agent session's report (Agent Streams S3), in the sharing system's four audiences (design 2026-11-06): the
++ // share row decides, through canView, and nobody else learns whether it exists — a report of Specific people is a
++ // 404 for a signed-in person who is not on the list, and for everyone signed out.
r.get('/s/:id', async (req, res) => {
const id = String(req.params.id || '');
const rep = sessionReports ? await sessionReports.get(id) : null;
if (!rep) return notFound(req, res);
-- const viewer = req.user ? String(req.user.sub || req.user.id || '') : '';
-- if (rep.visibility === 'private' && viewer !== rep.owner) return notFound(req, res);
-- return send(req, res, views.sessionReport({ report: rep, signedIn: Boolean(viewer) }));
++ const viewer = sharing.viewerOf(req.user);
++ if (!(await sharing.canView(viewer, rep))) return notFound(req, res);
++ const audience = await sharing.audienceOf(rep);
++ const isOwner = Boolean(viewer && viewer.subject.id === rep.owner);
++ const members = isOwner ? await sharing.memberSubjects(rep.id) : [];
++ // Only a signed-out visitor's copy of a public report may be stored by a shared cache. A signed-in reader's
++ // page carries their own parts — above all the owner's, which names the Share sheet's kind and id and the
++ // people the report is shared with — and Cloudflare on our zones does not honour Vary or Authorization, so a
++ // public header there could hand the owner's page to the next visitor. Signed in is always private, no-store.
++ const cacheControl = audience === 'public' && !viewer ? CACHE : 'private, no-store';
++ return send(req, res, views.sessionReport({ report: rep, signedIn: Boolean(viewer), isOwner, audience, members }), 200, cacheControl);
});
r.get('/rundown/t/:slug', async (req, res) => {
@@@ -210,13 +220,17 @@ function createPageRoutes(ctx) {
// Your AI (plan L40/L41): the ledger's own rows for this person, read in-process, never by a query value.
const subject = typeof user.subject_id === 'string' ? user.subject_id.trim() : '';
const from = new Date((ctx.now ? ctx.now() : Date.now()) - (days - 1) * 86400000).toISOString().slice(0, 10);
-- const [stats, recaps, speaker, ai] = await Promise.all([
++ const [stats, recaps, speaker, ai, reports] = 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),
subject && ctx.aiUsage ? ctx.aiUsage.forPerson({ subject, from, group: 'day' }).catch(() => null) : null,
++ subject && sessionReports ? sessionReports.listForOwner(subject, { limit: 20 }).catch(() => null) : null,
]);
-- return send(req, res, views.you({ user, days, stats, recaps, speaker, ai }), 200, 'private, no-store');
++ // Their own reports, each with the audience it has now, and the default for the next one.
++ const withAudience = reports ? await Promise.all(reports.map(async (r) => ({ ...r, audience: await sharing.audienceOf(r) }))) : null;
++ const defaultAudience = subject && sessionReports ? (await sessionReports.defaultAudience(subject)) || 'private' : 'private';
++ return send(req, res, views.you({ user, days, stats, recaps, speaker, ai, reports: withAudience, defaultAudience }), 200, 'private, no-store');
});
r.get('/c/:username', async (req, res) => {
ddiff --git a/server/search-index.js b/server/search-index.js
iindex b5f0b09..ce97469 100644
---- a/server/search-index.js
++++ b/server/search-index.js
@@@ -111,6 +111,19 @@ function createSearchIndex({ config, store, live, sessionReports = null, log = c
});
const timers = [];
++ /**
++ * One report's Search copy, brought level with its audience now (the share store's onAudienceChange calls this, in
++ * the same transaction as the audience change): in Everyone is a document, anything else a tombstone. Rule 4 says a
++ * report that leaves Everyone leaves search at once, not at the next hourly sweep.
++ */
++ async function syncReport(id, audience, t = null) {
++ const rep = await sessionReports.get(String(id));
++ const handle = t || store.db;
++ if (!rep) return await feed.remove(handle, 'session', String(id));
++ if (audience !== 'public') return await feed.remove(handle, 'session', String(id));
++ return await feed.sync(handle, 'session', rep);
++ }
++
async function sweep() {
const res = await feed.sweep();
const r = res.topic;
@@@ -136,7 +149,12 @@ function createSearchIndex({ config, store, live, sessionReports = null, log = c
out.stop();
}
-- return { feed, outbox: out, sweep, start, stop, status: () => out.status() };
++ return { feed, outbox: out, sweep, syncReport, start, stop, status: () => out.status() };
}
--module.exports = { createSearchIndex, describeTopic, describeSession, EVENT_TYPES };
++/** The feed the app built, for modules that need to sync one row outside a sweep (server/sharing.js). */
++let _instance = null;
++function instance() { return _instance; }
++function setInstance(index) { _instance = index; return index; }
++
++module.exports = { createSearchIndex, describeTopic, describeSession, EVENT_TYPES, instance, setInstance };
ddiff --git a/server/session-reports.js b/server/session-reports.js
iindex e79f2a9..d8f0c42 100644
---- a/server/session-reports.js
++++ b/server/session-reports.js
@@@ -147,15 +147,58 @@ function rowToReport(row) {
};
}
--function createSessionReports({ store, config, log = console }) {
++function createSessionReports({ store, config, log = console, sharing = null }) {
const db = store && store.db;
const base = String((config && config.baseUrl) || 'https://openvibe.report').replace(/\/+$/, '');
const urlFor = (id) => `${base}/s/${id}`;
++ const shares = sharing || require('./sharing');
-- /** Store the report. Idempotent per (source, task ref): a repeat answers the report that is already there. */
++ /**
++ * The owner's default audience for new reports (their own page sets it). No row is Only me, which is the S3
++ * default; the writing service's own visibility names one when it has a reason to.
++ */
++ async function defaultAudience(owner) {
++ if (!db || !owner) return null;
++ try {
++ const row = await db.prepare('SELECT audience FROM report_session_defaults WHERE owner_subject = ?').get(String(owner));
++ return row && shares.AUDIENCES.includes(row.audience) ? row.audience : null;
++ } catch { return null; }
++ }
++
++ /** Set the owner's default for the reports they make from now on. */
++ async function setDefaultAudience(owner, audience) {
++ if (!db) throw new Error('no database');
++ if (!shares.AUDIENCES.includes(audience)) throw new Error('unknown audience');
++ await db.prepare(`INSERT INTO report_session_defaults (owner_subject, audience, updated_at) VALUES (?, ?, now())
++ ON CONFLICT (owner_subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`).run(String(owner), audience);
++ return audience;
++ }
++
++ /**
++ * The audience a new report of this owner starts with: what the writing service named, else the owner's default,
++ * else Only me.
++ *
++ * A service naming Everyone or the link means it: that is the report's own audience. A service naming `private`
++ * has made no choice (it is the S3 default every writer sends), so the person's own default decides — "New
++ * reports: Only me ▾" on /you is theirs to change, and a tool that writes a report must not overrule it.
++ * The old column is written from the answer, so an older release reads the same thing.
++ */
++ async function startingAudience(owner, named) {
++ if (named === 'public' || named === 'unlisted') return shares.audienceFor(named);
++ const own = await defaultAudience(owner);
++ return own || 'private';
++ }
++
++ /**
++ * Store the report. Idempotent per (source, task ref): a repeat answers the report that is already there.
++ * The share row is registered in the same request as the write, with the audience the report starts with; a
++ * report that already has one keeps it (the owner's choice in the sheet is never overruled by a repeat write).
++ */
async function write(body) {
if (!db) throw new Error('no database');
const clean = cleanBody(body);
++ const audience = await startingAudience(clean.owner, body && body.visibility);
++ clean.visibility = shares.visibilityFor(audience);
const id = 'rps_' + contracts.ids.ulid();
try {
await db.prepare(`INSERT INTO report_sessions
@@@ -168,6 +211,9 @@ function createSessionReports({ store, config, log = console }) {
clean.summary, JSON.stringify(clean.timeline), JSON.stringify(clean.outputs), JSON.stringify(clean.proof),
clean.cost_usd, JSON.stringify(clean.models), JSON.stringify(clean.highlights),
clean.stream ? JSON.stringify(clean.stream) : null, clean.visibility);
++ // The sharing row is the access authority from now on; the column above is what an older release reads.
++ // A failure here never loses the report: it is written, and the backfill registers it on the next pass.
++ await shares.register(id, clean.owner, clean.visibility);
return { id, url: urlFor(id), created: true };
} catch (err) {
const again = await db.prepare('SELECT id FROM report_sessions WHERE source = ? AND task_ref = ?')
@@@ -220,15 +266,38 @@ function createSessionReports({ store, config, log = console }) {
});
}
-- /** The public reports, newest first. Only these are ever listed, indexed or in the sitemap. */
-- async function listPublic({ limit = 24 } = {}) {
++ /**
++ * The public reports, newest first. Only these are ever listed, indexed or in the sitemap.
++ *
++ * "Public" is the sharing system's answer, not the column's: a report whose owner moved it to Specific people or
++ * Only me after it was written is out of every list the moment they save, even before a backfill touches its
++ * column — and one the owner opened to Everyone is listed even if its column still says private. The column is the
++ * fallback for a report that has no share row yet (an old release, a row the backfill has not reached), which is
++ * what keeps an N-1 read correct.
++ */
++ async function listPublic({ limit = 24, after = null } = {}) {
if (!db) return [];
-- const rows = await db.prepare(
-- `SELECT * FROM report_sessions WHERE visibility = 'public' ORDER BY created_at DESC, id DESC LIMIT ?`)
-- .all(Math.max(1, Math.min(200, Number(limit) || 24)));
++ const cap = Math.max(1, Math.min(500, Number(limit) || 24));
++ const base = `SELECT r.* FROM report_sessions r
++ LEFT JOIN shares s ON s.service = 'report' AND s.kind = 'session-report' AND s.resource_id = r.id
++ WHERE COALESCE(s.audience, CASE r.visibility
++ WHEN 'public' THEN 'public' WHEN 'unlisted' THEN 'link' ELSE 'private' END) = 'public'`;
++ const rows = after == null
++ ? await db.prepare(`${base} ORDER BY r.id ASC LIMIT ?`).all(cap)
++ : await db.prepare(`${base} AND r.id > ? ORDER BY r.id ASC LIMIT ?`).all(String(after), cap);
return rows.map(rowToReport);
}
++ /**
++ * The public reports, newest first, for a page. (The sitemap and Search read listPublic's id order, which is the
++ * cursor its paging needs.)
++ */
++ async function listPublicNewest({ limit = 24 } = {}) {
++ const all = await listPublic({ limit: 500 });
++ return all.sort((a, b) => (String(b.created_at || '') < String(a.created_at || '') ? -1 : String(b.created_at || '') > String(a.created_at || '') ? 1 : (a.id < b.id ? 1 : -1)))
++ .slice(0, Math.max(1, Math.min(200, Number(limit) || 24)));
++ }
++
/** The public reports written today (the Rundown's "What agents built today"). */
async function listPublicToday({ limit = 24, now = () => new Date() } = {}) {
const all = await listPublic({ limit: 200 });
@@@ -237,6 +306,22 @@ function createSessionReports({ store, config, log = console }) {
return all.filter((r) => String(r.created_at || '').slice(0, 10) === day).slice(0, limit);
}
++ /** 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 [];
++ const rows = await db.prepare(
++ 'SELECT * FROM report_sessions WHERE owner = ? ORDER BY created_at DESC, id DESC LIMIT ?')
++ .all(String(owner), Math.max(1, Math.min(200, Number(limit) || 24)));
++ return rows.map(rowToReport);
++ }
++
++ /** One report with the audience it has now (the page's chip and the owner's Share button). */
++ async function getWithAudience(id) {
++ const rep = await get(id);
++ if (!rep) return null;
++ return { ...rep, audience: await shares.audienceOf(rep) };
++ }
++
/**
* Live's stream part: the agent stream a session streamed on, matched by the same source and task ref the tool
* used. The tool's report keeps its id; the stream is added to it.
@@@ -250,7 +335,10 @@ function createSessionReports({ store, config, log = console }) {
return id ? { id, url: urlFor(id) } : null;
}
-- return { write, writeStream, get, listPublic, listPublicToday, attachStream, urlFor };
++ return {
++ write, writeStream, get, getWithAudience, listPublic, listPublicToday, listForOwner, attachStream, urlFor,
++ defaultAudience, setDefaultAudience, startingAudience,
++ };
}
--module.exports = { createSessionReports, cleanBody, scrub, scrubDeep, rowToReport, ID_RE };
++module.exports = { createSessionReports, cleanBody, scrub, scrubDeep, rowToReport, ID_RE };
\\ No newline at end of file
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..8a20300
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,238 @@
++'use strict';
++/**
++ * Session reports in the sharing system (openvibe-sdk/share, share.v1), kind 'session-report'.
++ *
++ * The owner of a report chooses who sees it: Everyone, Anyone with the link, Specific people or Only me. That is the
++ * sharing system's own setting — the same words and the same sheet as everywhere else on the network — and it lives in
++ * the share row, not in a column of Report's own. The `visibility` column report_sessions already had is kept and is
++ * still written for N-1 (private → private, unlisted → link, public → public), so an older release reads what it always
++ * read; every read path here asks this module instead.
++ *
++ * A report registers itself when it is written, with the visibility the writing service named (private, the S3 default,
++ * when it named none). A report that already has a share row keeps its audience: a later write never overrules the
++ * owner's choice in the sheet.
++ *
++ * The SDK module is optional at runtime, as in Live: Report must boot and serve its other pages on a release whose
++ * pinned openvibe-sdk predates `share`. Without it the old column decides and only the old three levels work — fail
++ * closed, never open. The share_* tables are created by Report's own migration 0008 from the SDK's schema; nothing is
++ * created at runtime.
++ *
++ * The sheet's saves reach this module through the SDK's router (mountShareRouter in server/app.js); every read path
++ * calls canView. When the audience changes, onAudienceChange drops the report from Search in the same transaction
++ * (the sweep would too, an hour later; rule 4 says at once).
++ */
++const SERVICE = 'report';
++const KIND = 'session-report';
++/** The old column's three words, mapped 1:1 onto the four audiences (design, "How each product moves"). */
++const FROM_VISIBILITY = { private: 'private', unlisted: 'link', public: 'public' };
++/** And back: what the old column says for an audience. 'people' has no old word; Only me is the safe one. */
++const TO_VISIBILITY = { private: 'private', link: 'unlisted', public: 'public', people: 'private' };
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++const STAFF_ROLES = ['global_mod', 'admin', 'owner'];
++
++let state = { store: null, shares: null, loaded: false, network: null, events: null, notify: null, onAudienceChange: null };
++
++/**
++ * Build the share store. Called once by the app with the store and config; tests may pass their own network client and
++ * notification adapter.
++ */
++function init({ store, network = null, events = null, notify = null, onAudienceChange = null } = {}) {
++ state = { store: store || null, shares: null, loaded: false, network, events, notify, onAudienceChange };
++ return available();
++}
++
++function sdk() {
++ if (state.loaded) return state.shares;
++ state.loaded = true;
++ let mod;
++ try { mod = require('openvibe-sdk/share'); } catch { mod = null; }
++ if (!mod || typeof mod.createShares !== 'function' || !state.store || !state.store.db) return (state.shares = null);
++ try {
++ state.shares = mod.createShares({
++ db: state.store.db,
++ network: state.network || networkClient(),
++ events: state.events || eventsClient(),
++ notify: state.notify || (async () => {}),
++ onAudienceChange: state.onAudienceChange || defaultAudienceChange,
++ });
++ } catch (err) {
++ console.warn(`[Sharing] sharing unavailable: ${err.message}`);
++ state.shares = null;
++ }
++ return state.shares;
++}
++
++/**
++ * A report that leaves Everyone leaves Search at once: the same transaction writes the tombstone (search-feed's
++ * publication on the transaction the share store gave us). A failure is logged and never fails the owner's save — the
++ * hourly sweep is the backstop.
++ */
++async function defaultAudienceChange(ref, audience, tx) {
++ try {
++ const index = require('./search-index').instance && require('./search-index').instance();
++ if (index && typeof index.syncReport === 'function') await index.syncReport(ref.id, audience, tx);
++ } catch (err) {
++ console.warn(`[Sharing] the search copy of ${ref.id} was not updated: ${err.message}`);
++ }
++}
++
++/** Network's internal JSON client (the sheet's @name lookups and the people it shows). */
++function networkClient() {
++ try { return require('./data/network-json').client(); } catch { return { json: async () => { throw new Error('Network is not reachable'); } }; }
++}
++
++/**
++ * Report's events client. The share store queues every event in share_deliveries inside the change's transaction and
++ * flush() sends them here; a publish that fails leaves the row pending for the next flush, which is the retry.
++ */
++function eventsClient() {
++ return {
++ async publish(envelope) {
++ const client = eventsSingleton();
++ if (!client) throw new Error('Report has no events URL or OAuth client secret');
++ await client.publish(envelope);
++ },
++ };
++}
++
++let _events = null;
++function eventsSingleton() {
++ if (_events) return _events;
++ const { createEventsClient } = require('openvibe-sdk/events');
++ const { createClient } = require('openvibe-sdk/core');
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ let config;
++ try { config = require('./config').load(); } catch { return null; }
++ const url = config.events && config.events.url;
++ const secret = config.oauth && config.oauth.clientSecret;
++ if (!url || !secret) return null;
++ const tokenProvider = createServiceTokenClient({
++ tokenUrl: config.oauth.tokenUrl, clientId: config.oauth.clientId || 'report', clientSecret: secret,
++ scope: { 'openvibe.events': 'events.event.publish' },
++ });
++ _events = createEventsClient(createClient({ baseUrls: { events: url }, tokenProvider, retries: 0 }), { source: SERVICE });
++ return _events;
++}
++
++/** Whether the sharing system is installed in this release. */
++function available() { try { return !!sdk(); } catch { return false; } }
++
++/** The old column's word for an audience (what report_sessions.visibility says). */
++function visibilityFor(audience) { return TO_VISIBILITY[audience] || 'private'; }
++
++/** The audience the old column means. */
++function audienceFor(visibility) { return FROM_VISIBILITY[String(visibility || '').trim().toLowerCase()] || 'private'; }
++
++/** A person's signed-in viewer, as the sharing system reads one: { subject: { type, id }, staff? }. */
++function viewerOf(user) {
++ if (!user) return null;
++ const id = typeof user.subject_id === 'string' ? user.subject_id.trim() : '';
++ if (!id) return null;
++ return { subject: { type: 'user', id }, staff: user.staff === true || STAFF_ROLES.includes(String(user.role || '')) };
++}
++
++/**
++ * Register a report the moment it is written, with the audience its visibility means. An existing row is left alone
++ * (the owner's choice in the sheet wins over the write's default) and the answer says whether this call made the row.
++ */
++async function register(id, owner, visibility) {
++ const shares = sdk();
++ if (!shares || !owner) return false;
++ try {
++ await shares.register({ service: SERVICE, kind: KIND, id: String(id) }, {
++ owner_subject: { type: 'user', id: String(owner) },
++ audience: audienceFor(visibility),
++ });
++ return true;
++ } catch (err) {
++ // 409 share.exists: already registered, which is the normal repeat of an idempotent write.
++ if (err && (err.status === 409 || /share\.exists/.test(String(err.code || '')))) return false;
++ console.warn(`[Sharing] registration for ${id} failed: ${err.message}`);
++ return false;
++ }
++}
++
++/**
++ * May this viewer open the report? The share row decides. Without the sharing system (an old release, or no database)
++ * the old column decides, so nothing that worked stops working — and nothing hidden opens.
++ */
++async function canView(viewer, report) {
++ if (!report) return false;
++ const shares = sdk();
++ if (shares) {
++ try {
++ return await shares.canView(viewer || null, { service: SERVICE, kind: KIND, id: String(report.id) });
++ } catch (err) {
++ console.warn(`[Sharing] the access check for ${report.id} failed: ${err.message}`);
++ // A failing check is never an open door: fall through to the column.
++ }
++ }
++ const audience = audienceFor(report.visibility);
++ if (audience === 'public' || audience === 'link') return true;
++ const id = viewer && viewer.subject ? viewer.subject.id : null;
++ return Boolean(id && id === report.owner);
++}
++
++/** The audience a report has now: the share row's, else the column's. */
++async function audienceOf(report) {
++ if (!report) return 'private';
++ const row = await shareRow(report.id);
++ if (row && AUDIENCES.includes(row.audience)) return row.audience;
++ return audienceFor(report.visibility);
++}
++
++/** The raw share row (audience and revision), or null when the report has none. */
++async function shareRow(id) {
++ if (!state.store || !state.store.db) return null;
++ try {
++ return await state.store.db.maybe('SELECT audience, revision FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',
++ [SERVICE, KIND, String(id)]);
++ } catch { return null; }
++}
++
++/** The people a report is shared with, as subject ids (the count the owner's chip shows). */
++async function memberSubjects(id) {
++ if (!state.store || !state.store.db) return [];
++ try {
++ const rows = await state.store.db.many(`SELECT subject->>'id' AS id FROM share_members
++ WHERE service = $1 AND kind = $2 AND resource_id = $3 AND (expires_at IS NULL OR expires_at > NOW())`,
++ [SERVICE, KIND, String(id)]);
++ return rows.map((r) => r.id).filter(Boolean);
++ } catch { return []; }
++}
++
++/** The Share sheet's description of a report: its title and where it lives. */
++async function describe(ref) {
++ let base = 'https://openvibe.report';
++ try { base = String(require('./config').load().baseUrl || base).replace(/\/+$/, ''); } catch { /* the default */ }
++ const db = state.store && state.store.db;
++ const row = db ? await db.maybe('SELECT task_title FROM report_sessions WHERE id = $1', [String(ref.id)]).catch(() => null) : null;
++ return {
++ title: row && row.task_title ? row.task_title : 'A session report',
++ link: `${base}/s/${encodeURIComponent(String(ref.id))}`,
++ roles: ['viewer'],
++ // A session report is a story, not a document to edit, and it stays its writer's: no transfer.
++ transferable: false,
++ };
++}
++
++/** The share router, mounted at the app root so it sees /api/v1/<kind>/<id>/share. Null when the SDK has no sharing. */
++function router(options) {
++ const shares = sdk();
++ if (!shares) return null;
++ return shares.router({ service: SERVICE, ...options });
++}
++
++/** Send the sharing system's queued deliveries (the housekeeping loop calls this, never a page). */
++async function flushDeliveries() {
++ const shares = sdk();
++ if (!shares) return 0;
++ try { return await shares.flush(); } catch { return 0; }
++}
++
++module.exports = {
++ SERVICE, KIND, AUDIENCES, FROM_VISIBILITY, TO_VISIBILITY,
++ init, available, register, canView, audienceOf, shareRow, memberSubjects, describe, router, flushDeliveries,
++ visibilityFor, audienceFor, viewerOf,
++ _reset() { state = { store: null, shares: null, loaded: false, network: null, events: null, notify: null, onAudienceChange: null }; _events = null; },
++};
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 8633da6..2e6ce9b 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -366,7 +366,7 @@ function reportsSection(reports) {
+ '</section>';
}
--function sessionReport({ report: rep, signedIn = false } = {}) {
++function sessionReport({ report: rep, signedIn = false, isOwner = false, audience = 'private', members = [] } = {}) {
if (!rep) return notFound();
const out = reportOutcome(rep);
const proof = rep.proof || {};
@@@ -419,19 +419,37 @@ function sessionReport({ report: rep, signedIn = false } = {}) {
+ watch.map((o) => '<li><a href="' + (o.url ? esc(o.url) : '#') + '">' + esc(o.title) + '</a></li>').join('')
+ '</ul></section>';
}
++ // The one sheet (openvibe-shared/ov-share.js, loaded on the first press). For the owner, "Who can open it" is the
++ // access part, which calls the SDK's router at /api/v1/session-report/<id>/share; everyone else just sends the link.
++ const shareButton = '<button type="button" class="rp-btn" data-ov-share-sheet data-title="' + esc(rep.task.title) + '"'
++ + (isOwner ? ' data-kind="session-report" data-id="' + esc(rep.id) + '"'
++ : ' data-url="' + esc(canon('/s/' + rep.id)) + '"') + '>Share</button>';
body += '<section aria-labelledby="rp-ac-h"><h2 id="rp-ac-h">What to do</h2><p class="rp-act">'
-- + '<a class="rp-btn" href="/s/' + encodeURIComponent(rep.id) + '">Share this report</a>'
++ + shareButton
+ (signedIn ? ' <a class="rp-btn is-quiet" href="https://openvibe.codes/t/new?from=' + encodeURIComponent(rep.id) + '">Do this again</a>' : '')
-- + '</p></section>';
++ + '</p>'
++ + (isOwner ? '<p class="rp-meta">' + esc(sharingChip(audience, members)) + ' can open this report. '
++ + '<a href="/you">Change the default for new reports</a>.</p>' : '')
++ + '</section>';
return layout({
title: rep.task.title + ': the session report',
description: rep.summary,
body,
active: '/rundown',
path: '/s/' + rep.id,
++ // Only a report in Everyone is for search and crawlers; a link, a list of people and Only me are not.
++ robots: audience === 'public' ? 'index, follow' : 'noindex, nofollow',
});
}
++/** The four audiences in the words the sheet uses ("Everyone", "Anyone with the link", "Specific people", "Only me"). */
++function sharingChip(audience, members = []) {
++ if (audience === 'public') return 'Everyone';
++ if (audience === 'link') return 'Anyone with the link';
++ if (audience === 'people') return members.length === 1 ? 'You and 1 other person' : `You and ${members.length} people`;
++ return 'Only you';
++}
++
function topic({ topic: d } = {}) {
const t = d.topic || {};
const takes = (d.takes || []).slice(0, 40);
@@@ -539,7 +557,7 @@ ${lines.slice(1).map((l) => `<p class="rp-fine">${esc(l)}</p>`).join('')}
</section>`;
}
--function you({ user, days = 30, stats = null, recaps = null, speaker = null, ai = null } = {}) {
++function you({ user, days = 30, stats = null, recaps = null, speaker = null, ai = null, reports = null, defaultAudience = 'private' } = {}) {
const name = String(user.display_name || user.username || 'you');
const username = String(user.username || '');
const T = (stats && stats.totals) || {};
@@@ -562,11 +580,32 @@ ${(sp.episodes || []).length ? `<p class="rp-meta">${plural(sp.episodes.length,
</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}${youAi(ai, days)}${rundown}
++${streams}${money}${youAi(ai, days)}${youReports(reports, defaultAudience)}${rundown}
<section aria-labelledby="rp-you-next" class="rp-you-next"><h2 id="rp-you-next">Coming here</h2><p class="rp-fine">Your agents' work and what your computers ran. Only you see this page.</p></section>`;
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' });
}
++/**
++ * Your session reports, and the default for the ones you make from now on (design 2026-11-06, rule 1: "New reports:
++ * Only me ▾" on the person's own page). Each report's Share sheet still changes just that one.
++ */
++function youReports(reports = null, defaultAudience = 'private') {
++ if (!reports) return '';
++ const list = Array.isArray(reports) ? reports : [];
++ const options = ['public', 'link', 'people', 'private'].map((a) => `<option value="${esc(a)}"${a === defaultAudience ? ' selected' : ''}>${esc(sharingChip(a, 0).replace('Only you', 'Only me'))}</option>`).join('');
++ const rows = list.slice(0, 20).map((r) => `<li><a href="/s/${encodeURIComponent(r.id)}">${esc(r.task.title)}</a>`
++ + `<span class="rp-meta">${esc(sharingChip(r.audience || 'private', []))}</span></li>`).join('');
++ return `<section aria-labelledby="rp-you-sr"><h2 id="rp-you-sr">Your session reports</h2>
++<form class="rp-share-default" method="post" action="/api/v1/me/session-reports/default" data-ov-default-share>
++<label for="rp-you-def">New reports:</label>
++<select id="rp-you-def" name="audience">${options}</select>
++<button type="submit" class="rp-btn is-quiet">Save</button>
++</form>
++<p class="rp-fine">Each report's Share button changes just that one. Changing this never changes a report you already made.</p>
++${rows ? `<ul class="rp-chips rp-you-reports">${rows}</ul>` : '<p class="rp-meta">No reports yet. When an agent finishes a session, its report lands here.</p>'}
++</section>`;
++}
++
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>
@@@ -806,4 +845,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, sessionReport, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };
++module.exports = { home, stats, rundown, sessionReport, sharingChip, topic, episode, speaker, recap, channel, you, youSignedOut, developers, aiIndex, aiBoard, aiModel, aiExchange, staffOnly, usdNano, paretoRows, paretoChart, notFound, layout, setRelease, configure };
ddiff --git a/test/data.test.js b/test/data.test.js
iindex 570cf5d..9a8684d 100644
---- a/test/data.test.js
++++ b/test/data.test.js
@@@ -8,7 +8,7 @@ const assert = require('assert');
const { createNetworkClient } = require('../server/data/network');
const { createLiveClient } = require('../server/data/live');
const { isSlug, isStreamId, isUsername, normaliseDays } = require('../server/data/source');
--const { jsonResponse, check, done } = require('./helpers/boot');
++const { jsonResponse, recapResponse, check, done } = require('./helpers/boot');
const quiet = () => ({ warn() {}, error() {} });
@@@ -104,7 +104,7 @@ const quiet = () => ({ warn() {}, error() {} });
const urls = [];
const client = createLiveClient({
config: { liveUrl: 'http://live' },
-- fetchImpl: async (url) => { urls.push(String(url)); return jsonResponse({ n: urls.length }); },
++ fetchImpl: async (url) => { urls.push(String(url)); return recapResponse({ n: urls.length }); },
now: () => now,
log: quiet(),
});
@@@ -114,10 +114,10 @@ const quiet = () => ({ warn() {}, error() {} });
assert.strictEqual(urls.filter((u) => u.endsWith('/api/rundown/')).length, 2, 'the home is held for two minutes');
await client.recap(7);
-- now += 9 * 60_000;
++ now += 59_000;
await client.recap(7);
-- assert.strictEqual(urls.filter((u) => u.endsWith('/api/recap/7')).length, 1, 'a recap is held for ten minutes');
-- now += 2 * 60_000;
++ assert.strictEqual(urls.filter((u) => u.endsWith('/api/recap/7')).length, 1, 'a recap is held for a minute');
++ now += 2_000;
await client.recap(7);
assert.strictEqual(urls.filter((u) => u.endsWith('/api/recap/7')).length, 2);
@@@ -151,6 +151,72 @@ const quiet = () => ({ warn() {}, error() {} });
assert.strictEqual((await client.rundown()).n, 3, 'too old to serve: fetched while the visitor waits');
});
++ await check('after-show: an answer Live did not mark public is not held, and a 404 is a miss that is asked again', async () => {
++ let now = 0;
++ let calls = 0;
++ let answer = () => recapResponse({ recap: { stream_id: 7 } });
++ const client = createLiveClient({
++ config: { liveUrl: 'http://live' },
++ fetchImpl: async () => { calls++; return answer(); },
++ now: () => now,
++ log: quiet(),
++ });
++
++ // Live's own header decides: a private or no-store answer goes to this reader and is never kept.
++ answer = () => recapResponse({ recap: { stream_id: 7 } }, 'private, max-age=0');
++ await client.recap(7);
++ await client.recap(7);
++ assert.strictEqual(calls, 2, 'a non-public answer must not be cached');
++ answer = () => recapResponse({ recap: { stream_id: 7 } }, 'no-store');
++ await client.recap(7);
++ await client.recap(7);
++ assert.strictEqual(calls, 4, 'a no-store answer must not be cached');
++ answer = () => new Response(JSON.stringify({ recap: {} }), { status: 200, headers: { 'content-type': 'application/json' } });
++ await client.recap(7);
++ await client.recap(7);
++ assert.strictEqual(calls, 6, 'an answer with no Cache-Control at all must not be cached');
++
++ // public: held for a minute.
++ answer = () => recapResponse({ recap: { stream_id: 7 } });
++ await client.recap(7);
++ await client.recap(7);
++ assert.strictEqual(calls, 7, 'a public answer is held');
++
++ // The streamer made it private: once the minute is up Live answers 404, and the copy is dropped - not kept as
++ // a stale answer to serve while a refresh runs.
++ now += 61_000;
++ answer = () => jsonResponse({ error: 'No report for this stream' }, 404);
++ assert.strictEqual(await client.recap(7), null);
++ assert.strictEqual(calls, 8);
++ answer = () => jsonResponse({ error: 'No report for this stream' }, 404);
++ assert.strictEqual(await client.recap(7), null, 'a 404 is asked again, never answered from the old copy');
++ assert.strictEqual(calls, 9);
++ answer = () => recapResponse({ recap: { stream_id: 7, again: true } });
++ assert.strictEqual((await client.recap(7)).recap.again, true, 'the read after a 404 asks Live again');
++ assert.strictEqual(calls, 10);
++
++ // No stale copy is ever served for an after-show report: a failure is null, and the next read asks again.
++ answer = () => { throw new Error('connect ECONNREFUSED'); };
++ now += 61_000;
++ assert.strictEqual(await client.recap(7), null, 'an expired copy is not served while Live is down');
++ assert.strictEqual(calls, 11);
++ answer = () => recapResponse({ recap: { stream_id: 7, back: true } });
++ assert.strictEqual((await client.recap(7)).recap.back, true);
++ assert.strictEqual(calls, 12);
++
++ // A channel's list follows the same rule.
++ let channelCalls = 0;
++ const channel = createLiveClient({
++ config: { liveUrl: 'http://live' },
++ fetchImpl: async () => { channelCalls++; return recapResponse({ recaps: [] }, 'private'); },
++ now: () => 0,
++ log: quiet(),
++ });
++ await channel.channelRecaps('goosely');
++ await channel.channelRecaps('goosely');
++ assert.strictEqual(channelCalls, 2, 'a channel list Live marked private is not held');
++ });
++
await check('the cache keeps 1000 entries and drops the oldest first', async () => {
let calls = 0;
const client = createLiveClient({
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex f0e96c5..9a2013d 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -27,6 +27,11 @@ function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
}
++/** An after-show answer as OpenVibe.Live sends it: Cache-Control public, which is what lets Report hold it. */
++function recapResponse(body, cacheControl = 'public, max-age=60') {
++ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json', 'cache-control': cacheControl } });
++}
++
function defaults() {
return {
stats: {
@@@ -125,10 +130,12 @@ function stubFetch(data = {}, { health = { network: true, live: true } } = {}) {
if (m) return answers.episode == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.episode);
m = /^\/api\/rundown\/s\/([^/]+)$/.exec(p);
if (m) return answers.speaker == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.speaker);
++ // Live marks the after-show reports public (its recap routes set `public, max-age=60`); Report holds one only
++ // when it does. A 404 here is Live answering "no such report", exactly as the real route does.
m = /^\/api\/recap\/channel\/([^/]+)$/.exec(p);
-- if (m) return answers.channel == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.channel);
++ if (m) return answers.channel == null ? jsonResponse({ error: 'not found' }, 404) : recapResponse(answers.channel);
m = /^\/api\/recap\/(\d+)$/.exec(p);
-- if (m) return answers.recap == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.recap);
++ if (m) return answers.recap == null ? jsonResponse({ error: 'not found' }, 404) : recapResponse(answers.recap);
return jsonResponse({ error: 'no stub for ' + p }, 404);
};
@@@ -172,6 +179,7 @@ async function boot(opts = {}) {
shadow: opts.shadow,
...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),
...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),
++ ...(opts.shareNetwork ? { shareNetwork: opts.shareNetwork } : {}),
releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },
});
const server = await new Promise((resolve) => {
@@@ -221,4 +229,4 @@ function done() {
process.exit(failures ? 1 : 0);
}
--module.exports = { boot, check, done, stubFetch, jsonResponse, defaults, NETWORK, LIVE };
++module.exports = { boot, check, done, stubFetch, jsonResponse, recapResponse, defaults, NETWORK, LIVE };
ddiff --git a/test/session-report-sharing.test.js b/test/session-report-sharing.test.js
nnew file mode 100644
iindex 0000000..6256cb4
---- /dev/null
++++ b/test/session-report-sharing.test.js
@@@ -0,0 +1,376 @@
++'use strict';
++/**
++ * Who sees a session report (design 2026-11-06, "Report: session reports"): the owner chooses one of the sharing
++ * system's four audiences, and every read path asks the share row.
++ *
++ * node test/session-report-sharing.test.js
++ *
++ * What this proves:
++ * - the four audiences on the page and the API, for the owner, a person on the list, another signed-in person and
++ * someone signed out: Everyone and the link open for all; Specific people and Only me are a 404 for the last two,
++ * the same 404 a missing report gets, so nothing hints that it exists;
++ * - a report that leaves Everyone leaves the lists, the sitemap and Search at once;
++ * - the owner's default for new reports, and that the old visibility column still says what an older release reads;
++ * - the backfill maps private/unlisted/public to Only me/the link/Everyone, is idempotent, and never overrules an
++ * audience the owner chose;
++ * - the after-show cache: Live's public header decides, a 404 is a miss, and no stale copy is served.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { createSsoClient } = require('openvibe-sdk/sso');
++const { boot, check, done } = require('./helpers/boot');
++const sharing = require('../server/sharing');
++const { plan, backfill } = require('../scripts/backfill-session-shares');
++
++const OWNER = 'usr_7M1EV8N2FW9P3GXAQ4HYBR5JZC';
++const FRIEND = 'usr_EV8N2FW9P3GXAQ4HYBR5JZCS6K';
++const STRANGER = 'usr_N2FW9P3GXAQ4HYBR5JZCS6K0DT';
++const OTHER_OWNER = 'usr_W9P3GXAQ4HYBR5JZCS6K0DT7M1';
++
++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 at = Math.floor(Date.now() / 1000);
++ const body = b64({ iss: 'https://openvibe.network', aud: ['openvibe.network'], iat: at, exp: at + 600, ...claims });
++ const sig = crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), privateKey).toString('base64url');
++ return `${head}.${body}.${sig}`;
++}
++const asOwner = token({ sub: '7', subject_id: OWNER, username: 'goosely', display_name: 'goosely' });
++const asFriend = token({ sub: '8', subject_id: FRIEND, username: 'friend', display_name: 'friend' });
++const asStranger = token({ sub: '9', subject_id: STRANGER, username: 'stranger', display_name: 'stranger' });
++const cookie = (t) => ({ cookie: `ov_token=${t}` });
++
++function body(over = {}) {
++ return {
++ owner: OWNER,
++ source: 'codes',
++ task: { title: 'Ship the checkout page', ref: 'tsk_01JAN2C3D4E5F6G7H8J9K0MNPS' },
++ started_at: '2026-11-06T18:00:00.000Z',
++ ended_at: '2026-11-06T18:42:00.000Z',
++ worked_seconds: 2520,
++ outcome: 'done',
++ summary: 'Asked for a checkout page; the agent built it and ran the tests.',
++ timeline: [{ at: '2026-11-06T18:20:00.000Z', title: 'Run the tests', status: 'passed' }],
++ outputs: [],
++ proof: { tests: { passed: 12, failed: 0 }, screenshots: [], review: null },
++ cost_usd: 0.31,
++ models: ['claude-sonnet-4'],
++ highlights: [],
++ visibility: 'private',
++ ...over,
++ };
++}
++
++/** The person Network's identity lookups know, and the key directory's answers for them. */
++function identityNetwork() {
++ const people = {
++ friend: { subject: { type: 'user', id: FRIEND }, username: 'friend', display_name: 'friend', banned: false, deleted: false },
++ stranger: { subject: { type: 'user', id: STRANGER }, username: 'stranger', display_name: 'stranger', banned: false, deleted: false },
++ goosely: { subject: { type: 'user', id: OWNER }, username: 'goosely', display_name: 'goosely', banned: false, deleted: false },
++ };
++ return {
++ async json({ path: p, method, query, json }) {
++ if (p === '/internal/identity/resolve') {
++ const found = people[String((query && query.username) || '').toLowerCase()];
++ if (!found) { const err = new Error('not found'); err.status = 404; throw err; }
++ return found;
++ }
++ if (p === '/internal/identity/resolve-batch') {
++ const results = {};
++ for (const id of (json && json.subject_ids) || []) {
++ const found = Object.values(people).find((x) => x.subject.id === id);
++ results[id] = found || null;
++ }
++ return { results };
++ }
++ if (/^\/api\/v1\/people\/.+\/keys$/.test(p)) { const err = new Error('no locker'); err.status = 404; throw err; }
++ const err = new Error('no such route ' + p);
++ err.status = 404;
++ throw err;
++ },
++ };
++}
++
++(async () => {
++ const verifyServiceToken = async () => ({ ok: true, claims: { sub: 'svc:codes', cap: ['report.session.write'], aud: ['openvibe.report'] } });
++ const sso = createSsoClient({
++ site: 'report', baseUrl: 'https://openvibe.report', clientId: 'report', clientSecret: 'x',
++ networkUrl: 'https://openvibe.network', publicKey: publicKey.export({ type: 'spki', format: 'pem' }),
++ log: { warn() {}, info() {}, log() {}, error() {} },
++ });
++ const t = await boot({ db: true, sso, verifyServiceToken, shareNetwork: identityNetwork(), now: () => Date.parse('2026-11-06T20:00:00Z') });
++ const db = t.ctx.store.db;
++ const write = async (over) => {
++ const r = await t.get('/internal/session-reports', { json: body(over), headers: { authorization: 'Bearer good' } });
++ assert.strictEqual(r.status, 201, r.text.slice(0, 200));
++ return r.json().id;
++ };
++ const setShare = async (id, input, as = asOwner) => {
++ const r = await t.get(`/api/v1/session-report/${id}/share`, {
++ method: 'PUT', json: input, headers: { ...cookie(as), 'x-requested-with': 'ov-share' },
++ });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ return r.json();
++ };
++ const audienceOf = (id) => db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['report', 'session-report', id]).then((r) => r && r.audience);
++
++ try {
++ await check('a new report is Only me by default, in the share row and in the old column', async () => {
++ const id = await write({});
++ assert.strictEqual(await audienceOf(id), 'private', 'the share row says Only me');
++ const row = await db.maybe('SELECT visibility FROM report_sessions WHERE id = $1', [id]);
++ assert.strictEqual(row.visibility, 'private', 'the old column is still written for N-1');
++ const page = await t.get('/s/' + id, { headers: cookie(asOwner) });
++ assert.strictEqual(page.status, 200, 'the owner opens their own report');
++ assert.ok(page.text.includes('Only you can open this report'), 'the owner sees the audience');
++ });
++
++ await check('Everyone: the page and the API open for all four viewers, cached only for a signed-out reader', async () => {
++ const id = await write({ task: { title: 'Everyone one', ref: 'tsk_everyone' }, visibility: 'public' });
++ assert.strictEqual(await audienceOf(id), 'public');
++ for (const [who, headers] of [['owner', cookie(asOwner)], ['friend', cookie(asFriend)], ['stranger', cookie(asStranger)], ['signed out', {}]]) {
++ const page = await t.get('/s/' + id, { headers });
++ assert.strictEqual(page.status, 200, `the page opens for the ${who}`);
++ // Only the signed-out copy may enter a shared cache: a signed-in page carries that reader's own parts,
++ // and Cloudflare on our zones ignores Vary and Authorization.
++ const expected = who === 'signed out' ? 'public, max-age=120' : 'private, no-store';
++ assert.strictEqual(page.headers.get('cache-control'), expected, `the ${who} page's cache policy`);
++ assert.match(page.text, /<meta name="robots" content="index, follow">/);
++ const api = await t.get('/api/v1/session-reports/' + id, { headers });
++ assert.strictEqual(api.status, 200, `the API answers the ${who}`);
++ assert.strictEqual(api.headers.get('cache-control'), expected, `the ${who} API answer's cache policy`);
++ assert.strictEqual(api.json().audience, 'public');
++ }
++ const ownerPage = await t.get('/s/' + id, { headers: cookie(asOwner) });
++ assert.ok(ownerPage.text.includes('data-ov-share-sheet') && ownerPage.text.includes('data-kind="session-report"') && ownerPage.text.includes(`data-id="${id}"`), 'the owner gets the access sheet');
++ assert.ok(ownerPage.text.includes('Everyone can open this report'));
++ const strangerPage = await t.get('/s/' + id, { headers: cookie(asStranger) });
++ assert.ok(strangerPage.text.includes(`data-url="https://openvibe.report/s/${id}"`), 'everyone else gets the link part of the sheet');
++ assert.ok(!strangerPage.text.includes('data-kind="session-report"'), 'and never the access part');
++ });
++
++ await check('a shared cache never receives the owner\'s copy of a public report', async () => {
++ const id = await write({ task: { title: 'Shared with a few', ref: 'tsk_shared_cache' }, visibility: 'public' });
++ await setShare(id, { audience: 'people', members: [{ name: 'friend', role: 'viewer' }] });
++ await setShare(id, { audience: 'public' });
++ const friend = await db.maybe('SELECT subject FROM share_members WHERE resource_id = $1', [id]);
++ const friendId = friend && friend.subject && friend.subject.id;
++ assert.strictEqual(friendId, FRIEND, 'the report is shared with someone, so the owner\'s page has a list to leak');
++
++ // The owner's page names the Share sheet's kind and id and says who else may open it: a shared cache must
++ // never store it, whatever header the reader's own browser would accept.
++ const ownerPage = await t.get('/s/' + id, { headers: cookie(asOwner) });
++ assert.strictEqual(ownerPage.status, 200);
++ assert.strictEqual(ownerPage.headers.get('cache-control'), 'private, no-store', 'the owner\'s page is private, no-store');
++ assert.ok(ownerPage.text.includes('data-kind="session-report"') && ownerPage.text.includes(`data-id="${id}"`));
++ assert.ok(ownerPage.text.includes('Everyone can open this report'));
++
++ // The signed-out copy is the only one a shared cache may keep, and it carries none of that.
++ const anon = await t.get('/s/' + id);
++ assert.strictEqual(anon.status, 200);
++ assert.strictEqual(anon.headers.get('cache-control'), 'public, max-age=120', 'a signed-out reader\'s copy is the cacheable one');
++ assert.ok(!anon.text.includes('data-kind="session-report"'), 'no access sheet for a signed-out reader');
++ assert.ok(!anon.text.includes(`data-id="${id}"`), 'and never the owner\'s report id as a sheet target');
++ assert.ok(!/can open this report/.test(anon.text), 'no audience line, so no member count');
++ assert.ok(!anon.text.includes(FRIEND), 'and no member id anywhere in the page');
++ assert.ok(!/You and \d+ other/.test(anon.text) && !/You and \d+ people/.test(anon.text), 'and no "You and N" line');
++ // The signed-in reader who is not the owner gets their own private copy too: the page is never the owner's.
++ assert.strictEqual((await t.get('/s/' + id, { headers: cookie(asStranger) })).headers.get('cache-control'), 'private, no-store');
++ // The list and the owner's own page are private as well.
++ assert.strictEqual((await t.get('/api/v1/session-reports', { headers: cookie(asOwner) })).headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual((await t.get('/api/v1/session-reports')).headers.get('cache-control'), 'public, max-age=120', 'a signed-out list is the public one');
++ assert.strictEqual((await t.get('/you', { headers: cookie(asOwner) })).headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual((await t.get('/rundown')).headers.get('cache-control'), 'public, max-age=120', 'the Rundown carries only public cards, so it stays cacheable');
++ });
++
++ await check('Anyone with the link: opens for all four, never listed, cached private and not indexed', async () => {
++ const id = await write({ task: { title: 'Link one', ref: 'tsk_link' }, visibility: 'unlisted' });
++ assert.strictEqual(await audienceOf(id), 'link');
++ for (const [who, headers] of [['owner', cookie(asOwner)], ['friend', cookie(asFriend)], ['stranger', cookie(asStranger)], ['signed out', {}]]) {
++ const page = await t.get('/s/' + id, { headers });
++ assert.strictEqual(page.status, 200, `the page opens for the ${who}`);
++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store', `the ${who} page is not cached`);
++ assert.match(page.text, /<meta name="robots" content="noindex, nofollow">/);
++ assert.strictEqual((await t.get('/api/v1/session-reports/' + id, { headers })).status, 200);
++ }
++ const listed = await t.ctx.sessionReports.listPublic({ limit: 50 });
++ assert.ok(!listed.some((r) => r.id === id), 'a link report is not in any list');
++ assert.ok(!(await t.get('/sitemap.xml')).text.includes('/s/' + id), 'and not in the sitemap');
++ });
++
++ await check('Specific people: the owner and the people on the list, a 404 that says nothing for anyone else', async () => {
++ const id = await write({ task: { title: 'People one', ref: 'tsk_people' } });
++ const saved = await setShare(id, { audience: 'people', members: [{ name: 'friend', role: 'viewer' }] });
++ assert.strictEqual(saved.audience, 'people');
++ assert.strictEqual(saved.members.length, 1);
++ assert.strictEqual(await audienceOf(id), 'people');
++
++ const owner = await t.get('/s/' + id, { headers: cookie(asOwner) });
++ assert.strictEqual(owner.status, 200);
++ assert.ok(owner.text.includes('You and 1 other person can open this report'), 'the owner sees who else');
++ assert.ok(owner.text.includes('People one'));
++ assert.strictEqual((await t.get('/api/v1/session-reports/' + id, { headers: cookie(asOwner) })).status, 200);
++
++ const friend = await t.get('/s/' + id, { headers: cookie(asFriend) });
++ assert.strictEqual(friend.status, 200, 'a person on the list opens it');
++ assert.strictEqual(friend.headers.get('cache-control'), 'private, no-store');
++ assert.ok(!friend.text.includes('data-kind="session-report"'), 'and does not get the owner\'s sheet');
++ const friendApi = await t.get('/api/v1/session-reports/' + id, { headers: cookie(asFriend) });
++ assert.strictEqual(friendApi.status, 200);
++ assert.strictEqual(friendApi.json().audience, 'people');
++
++ const missing = await t.get('/s/rps_01AAAAAAAAAAAAAAAAAAAAAAAA');
++ const stranger = await t.get('/s/' + id, { headers: cookie(asStranger) });
++ const out = await t.get('/s/' + id);
++ assert.strictEqual(stranger.status, 404, 'another signed-in person gets a 404');
++ assert.strictEqual(out.status, 404, 'a signed-out reader gets a 404');
++ assert.strictEqual(stranger.text, missing.text, 'the same page a missing report gets: no hint that it exists');
++ assert.strictEqual(out.text, missing.text);
++ assert.strictEqual(stranger.headers.get('cache-control'), missing.headers.get('cache-control'));
++
++ const strangerApi = await t.get('/api/v1/session-reports/' + id, { headers: cookie(asStranger) });
++ assert.strictEqual(strangerApi.status, 404);
++ assert.strictEqual(strangerApi.json().code, 'report.session_not_found');
++ assert.strictEqual((await t.get('/api/v1/session-reports/' + id)).status, 404, 'and the API is a 404 signed out');
++ assert.ok(!(await t.ctx.sessionReports.listPublic({ limit: 50 })).some((r) => r.id === id));
++ });
++
++ await check('Only me: the owner alone, a 404 for the person who was on the list', async () => {
++ const id = await write({ task: { title: 'Mine one', ref: 'tsk_mine' } });
++ await setShare(id, { audience: 'people', members: [{ name: 'friend', role: 'viewer' }] });
++ assert.strictEqual((await t.get('/s/' + id, { headers: cookie(asFriend) })).status, 200);
++ await setShare(id, { audience: 'private' });
++ assert.strictEqual(await audienceOf(id), 'private');
++ assert.strictEqual((await t.get('/s/' + id, { headers: cookie(asOwner) })).status, 200);
++ assert.strictEqual((await t.get('/s/' + id, { headers: cookie(asFriend) })).status, 404, 'leaving the list closes it at once');
++ assert.strictEqual((await t.get('/api/v1/session-reports/' + id, { headers: cookie(asFriend) })).status, 404);
++ });
++
++ await check('the owner of another report cannot change this one\'s audience', async () => {
++ const id = await write({ task: { title: 'Not yours', ref: 'tsk_not_yours' } });
++ const r = await t.get(`/api/v1/session-report/${id}/share`, {
++ method: 'PUT', json: { audience: 'public' }, headers: { ...cookie(asStranger), 'x-requested-with': 'ov-share' },
++ });
++ assert.strictEqual(r.status, 404, 'the sheet\'s store refuses anyone who is not the owner, as a 404');
++ assert.strictEqual(await audienceOf(id), 'private');
++ assert.strictEqual((await t.get('/s/' + id, { headers: cookie(asStranger) })).status, 404);
++ });
++
++ await check('a report that leaves Everyone leaves the list, the sitemap and Search at once', async () => {
++ const id = await write({ task: { title: 'Leaving one', ref: 'tsk_leaving' }, visibility: 'public' });
++ assert.ok((await t.ctx.sessionReports.listPublic({ limit: 50 })).some((r) => r.id === id));
++ assert.ok((await t.get('/sitemap.xml')).text.includes('/s/' + id));
++ // Search had it: the sweep put a document in the outbox.
++ const searchIndex = require('../server/search-index');
++ const index = searchIndex.instance();
++ assert.ok(index, 'the app built the search feed');
++ await index.sweep();
++ const before = (await db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope)
++ .filter((e) => e && e.payload && e.payload.type === 'session' && e.payload.id === id);
++ assert.ok(before.length, 'Search holds the public report');
++
++ await setShare(id, { audience: 'private' });
++ // Out of every list, at once - the share row wins over the column, which still says public until a backfill.
++ assert.ok(!(await t.ctx.sessionReports.listPublic({ limit: 50 })).some((r) => r.id === id), 'out of the lists');
++ assert.ok(!(await t.get('/sitemap.xml')).text.includes('/s/' + id), 'out of the sitemap');
++ assert.ok(!(await t.get('/api/v1/session-reports')).json().reports.some((r) => r.id === id), 'out of the API list');
++ assert.strictEqual((await t.get('/s/' + id)).status, 404, 'and no longer a page');
++ const after = (await db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope)
++ .filter((e) => e && e.payload && e.payload.type === 'session' && e.payload.id === id);
++ assert.ok(after.length > before.length, 'a tombstone went out in the same transaction as the audience change');
++ assert.strictEqual(after[after.length - 1].event_type, 'report.index_document.deleted', 'the last word for Search is the tombstone');
++
++ // And it comes back when the owner widens it again.
++ await setShare(id, { audience: 'public' });
++ assert.ok((await t.ctx.sessionReports.listPublic({ limit: 50 })).some((r) => r.id === id), 'back in the list');
++ });
++
++ await check('the owner sets their default for new reports, and it is Only me until they change it', async () => {
++ const signedOut = await t.get('/api/v1/me/session-reports/default', { method: 'POST', json: { audience: 'public' } });
++ assert.strictEqual(signedOut.status, 401);
++ const crossSite = await t.get('/api/v1/me/session-reports/default', { method: 'POST', json: { audience: 'public' }, headers: { ...cookie(asOwner), 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(crossSite.status, 403, 'a cross-site save is refused');
++ const bad = await t.get('/api/v1/me/session-reports/default', { method: 'POST', json: { audience: 'everyone' }, headers: { ...cookie(asOwner), 'x-requested-with': 'ov-share' } });
++ assert.strictEqual(bad.status, 422);
++ const ok = await t.get('/api/v1/me/session-reports/default', { method: 'POST', json: { audience: 'link' }, headers: { ...cookie(asOwner), 'x-requested-with': 'ov-share' } });
++ assert.strictEqual(ok.status, 200);
++ assert.deepStrictEqual(ok.json(), { audience: 'link', visibility: 'unlisted' });
++
++ const id = await write({ task: { title: 'By the default', ref: 'tsk_default' } });
++ assert.strictEqual(await audienceOf(id), 'link', 'the next report starts at the owner\'s default');
++ assert.strictEqual((await db.maybe('SELECT visibility FROM report_sessions WHERE id = $1', [id])).visibility, 'unlisted', 'and the old column agrees');
++
++ // The default is the owner's, not everyone's.
++ const other = await write({ task: { title: 'Another owner', ref: 'tsk_other_owner' }, owner: OTHER_OWNER });
++ assert.strictEqual(await audienceOf(other), 'private');
++
++ const you = await t.get('/you', { headers: cookie(asOwner) });
++ assert.ok(you.text.includes('data-ov-default-share'), 'the owner can change it on their own page');
++ assert.ok(you.text.includes('New reports:'));
++ assert.ok(you.text.includes('Ship the checkout page'), 'their own reports are listed with their audience');
++ assert.ok(you.text.includes('Anyone with the link'));
++ // Back to Only me for the rest of the run.
++ await t.get('/api/v1/me/session-reports/default', { method: 'POST', json: { audience: 'private' }, headers: { ...cookie(asOwner), 'x-requested-with': 'ov-share' } });
++ });
++
++ await check('the backfill maps the column, writes nothing in a dry run, and never overrules the owner', async () => {
++ // Two reports as an older release left them: a share row is what this script adds.
++ const rows = [
++ ['rps_01BBBBBBBBBBBBBBBBBBBBBBBB', OWNER, 'private'],
++ ['rps_01CCCCCCCCCCCCCCCCCCCCCCCC', OWNER, 'unlisted'],
++ ['rps_01DDDDDDDDDDDDDDDDDDDDDDDD', OWNER, 'public'],
++ ];
++ for (const [id, owner, visibility] of rows) {
++ await db.prepare(`INSERT INTO report_sessions (id, owner, source, task_ref, task_title, outcome, summary, visibility, created_at, updated_at)
++ VALUES (?, ?, 'codes', ?, 'Old one', 'done', 'Written before sharing.', ?, now(), now())`).run(id, owner, 'tsk_' + id, visibility);
++ }
++ // One the owner has already chosen for: the script must leave its audience exactly as it is.
++ const chosen = await write({ task: { title: 'Chosen', ref: 'tsk_chosen' } });
++ await setShare(chosen, { audience: 'public' });
++
++ const before = await plan(db, { limit: 1000 });
++ assert.deepStrictEqual(before.map((p) => [p.id, p.from, p.to]), [
++ ['rps_01BBBBBBBBBBBBBBBBBBBBBBBB', 'private', 'private'],
++ ['rps_01CCCCCCCCCCCCCCCCCCCCCCCC', 'unlisted', 'link'],
++ ['rps_01DDDDDDDDDDDDDDDDDDDDDDDD', 'public', 'public'],
++ ], 'private -> Only me, unlisted -> the link, public -> Everyone; a report with a row is not in the plan');
++
++ const written = (await db.many('SELECT 1 AS one FROM shares WHERE kind = $1', ['session-report'])).length;
++ const dry = await backfill({ db, register: sharing.register, apply: false });
++ assert.strictEqual(dry.made, 0);
++ assert.strictEqual((await db.many('SELECT 1 AS one FROM shares WHERE kind = $1', ['session-report'])).length, written, 'a dry run wrote nothing');
++
++ const done = await backfill({ db, register: sharing.register, apply: true });
++ assert.strictEqual(done.planned, 3);
++ assert.strictEqual(done.made, 3);
++ assert.strictEqual(done.failed, 0);
++ assert.strictEqual(await audienceOf('rps_01BBBBBBBBBBBBBBBBBBBBBBBB'), 'private');
++ assert.strictEqual(await audienceOf('rps_01CCCCCCCCCCCCCCCCCCCCCCCC'), 'link');
++ assert.strictEqual(await audienceOf('rps_01DDDDDDDDDDDDDDDDDDDDDDDD'), 'public');
++ assert.strictEqual(await audienceOf(chosen), 'public', 'the owner\'s own choice is untouched');
++ const col = await db.maybe('SELECT visibility FROM report_sessions WHERE id = $1', ['rps_01DDDDDDDDDDDDDDDDDDDDDDDD']);
++ assert.strictEqual(col.visibility, 'public', 'the column is left as it was');
++
++ const again = await backfill({ db, register: sharing.register, apply: true });
++ assert.strictEqual(again.planned, 0, 'idempotent: a second run finds nothing to do');
++ assert.strictEqual(again.made, 0);
++ assert.strictEqual((await db.many('SELECT 1 AS one FROM shares WHERE kind = $1', ['session-report'])).length, written + 3);
++ });
++
++ await check('the after-show page shows what Live answered, and a 404 from Live takes it down', async () => {
++ const live = require('../server/data/live');
++ const { jsonResponse, recapResponse } = require('./helpers/boot');
++ let answer = () => recapResponse({ recap: { stream_id: 5, title: 'The after-show' } });
++ let now = 0;
++ const client = live.createLiveClient({ config: { liveUrl: 'http://live' }, fetchImpl: async () => answer(), now: () => now, log: { warn() {} } });
++ assert.ok((await client.recap(5)).recap.title, 'a public answer is shown');
++ answer = () => jsonResponse({ error: 'No report for this stream' }, 404);
++ now += 61_000;
++ assert.strictEqual(await client.recap(5), null, 'Live answering 404 is no report, at once');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });