openvibers/openvibe.report Commits
Session reports: the owner chooses who sees it (#110)
Changes
nnew file mode 100644@@@ -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';nnew file mode 100644@@@ -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 };@@@ -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 ─────────────────────@@@ -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 = conreturn 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()]) };nnew file mode 100644@@@ -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 };@@@ -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 };@@@ -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, aiExchangereturn 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, aiExchangereturn 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@@@ -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) => {@@@ -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 = cout.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 };@@@ -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 filennew file mode 100644@@@ -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; },++};@@@ -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 };@@@ -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({@@@ -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 };nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -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';
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -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 };
@@ -38,6 +38,7 @@ const createAiExchangeConfig = require('./ai-exchange-config').create;
@@ -38,6 +38,7 @@ const createAiExchangeConfig = require('./ai-exchange-config').create;
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
const { createSessionReports } = require('./session-reports');
const { createSessionReports } = require('./session-reports');
const { createSessionReportRoutes } = require('./http/session-reports');
const { createSessionReportRoutes } = require('./http/session-reports');
+const sharing = require('./sharing');
const { createShadow } = require('./analytics/shadow');
const { createShadow } = require('./analytics/shadow');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes, createPublicPages } = require('./http/pages');
const { createPageRoutes, createPublicPages } = require('./http/pages');
@@ -51,6 +52,29 @@ const { PUBLIC_DIR, assetVersion } = require('./assets');
@@ -51,6 +52,29 @@ const { PUBLIC_DIR, assetVersion } = require('./assets');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
const PAGE_CACHE = 'public, max-age=120';
const PAGE_CACHE = 'public, max-age=120';
+/**
+ * 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 */
/** opts: config, store, fetchImpl, log, now, network, live, billing, analytics, shadow, sso, releaseEnv */
async function createApp(opts = {}) {
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
@@ -76,8 +100,13 @@ async function createApp(opts = {}) {
@@ -76,8 +100,13 @@ async function createApp(opts = {}) {
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
const shadow = opts.shadow || (store ? createShadow({ store, network, now, log }) : null);
// The report of every agent session (Agent Streams S3): one table, one route, one page.
// The report of every agent session (Agent Streams S3): one table, one route, one page.
const sessionReports = opts.sessionReports || (store ? createSessionReports({ store, config, log }) : null);
const sessionReports = opts.sessionReports || (store ? createSessionReports({ store, config, log }) : null);
+ // 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).
// 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);
const searchIndex = opts.searchIndex || (store ? createSearchIndex({ config, store, live, sessionReports, log }) : null);
+ if (searchIndex) require('./search-index').setInstance(searchIndex);
views.configure({ baseUrl: config.baseUrl });
views.configure({ baseUrl: config.baseUrl });
// A person's sign-in (the `report` client, the same id and secret Report already reads Billing with).
// 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 = {}) {
@@ -188,6 +217,34 @@ async function createApp(opts = {}) {
app.use('/auth', sso.router(express));
app.use('/auth', sso.router(express));
app.use(sso.optionalAuth());
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) ──
// ── 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 }));
if (sessionReports) app.use('/internal', createSessionReportRoutes({ config, sessionReports, log, verify: opts.verifyServiceToken }));
@@ -207,7 +264,7 @@ async function createApp(opts = {}) {
@@ -207,7 +264,7 @@ async function createApp(opts = {}) {
legacyHeaders: false,
legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}));
}));
- app.use('/api/v1', createApi({ network, live, 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 }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Crawl artifacts, then the pages ─────────────────────
// ── Crawl artifacts, then the pages ─────────────────────
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
* a 404 without asking Live.
* a 404 without asking Live.
*/
*/
const { createCache } = require('./cache');
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;
const SERVE_STALE_MS = 30 * 60_000;
@@ -25,8 +25,10 @@ const TTL = {
@@ -25,8 +25,10 @@ const TTL = {
topic: 5 * 60_000,
topic: 5 * 60_000,
episode: 5 * 60_000,
episode: 5 * 60_000,
speaker: 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
@@ -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');
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) {
function recap(streamId) {
const id = streamIdOf(streamId);
const id = streamIdOf(streamId);
if (!id) return null;
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) {
function channelRecaps(username) {
if (!isUsername(username)) return null;
if (!isUsername(username)) return null;
const key = username.toLowerCase();
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()]) };
return { rundown, topics, topic, episode, speaker, recap, channelRecaps, cache, baseUrl, ttlMs: TTL, settled: () => Promise.all([...refreshing.values()]) };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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 };
@@ -58,6 +58,37 @@ async function requestJson(url, { fetchImpl = globalThis.fetch, log = console, l
@@ -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. */
/** ?days=7|30|90; anything else is 30, exactly as OpenVibe.Network's own route reads it. */
function normaliseDays(value) {
function normaliseDays(value) {
const raw = Array.isArray(value) ? value[0] : value;
const raw = Array.isArray(value) ? value[0] : value;
@@ -78,4 +109,4 @@ function streamIdOf(value) {
@@ -78,4 +109,4 @@ function streamIdOf(value) {
const isStreamId = (value) => streamIdOf(value) !== null;
const isStreamId = (value) => streamIdOf(value) !== null;
const isUsername = (value) => typeof value === 'string' && USERNAME_RE.test(value);
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 };
@@ -26,10 +26,11 @@ const { createServiceAuth, CAPS } = require('../auth');
@@ -26,10 +26,11 @@ const { createServiceAuth, CAPS } = require('../auth');
const { GROUPS: AI_USAGE_GROUPS, PERSON_GROUPS } = require('../analytics/ai-usage');
const { GROUPS: AI_USAGE_GROUPS, PERSON_GROUPS } = require('../analytics/ai-usage');
const GROUPS = Object.keys(AI_USAGE_GROUPS);
const GROUPS = Object.keys(AI_USAGE_GROUPS);
const designHealth = require('../data/design-health');
const designHealth = require('../data/design-health');
+const sharing = require('../sharing');
const CACHE = 'public, max-age=120';
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 r = express.Router();
const serviceAuth = createServiceAuth({ config, now });
const serviceAuth = createServiceAuth({ config, now });
@@ -43,6 +44,14 @@ function createApi({ network, live, analytics = null, aiUsage = null, aiExchange
@@ -43,6 +44,14 @@ function createApi({ network, live, analytics = null, aiUsage = null, aiExchange
return next();
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.
// The personal routes: private and never cached, even when they refuse the caller, and their own limit.
const personalLimit = rateLimit({
const personalLimit = rateLimit({
windowMs: 60_000,
windowMs: 60_000,
@@ -115,6 +124,35 @@ function createApi({ network, live, analytics = null, aiUsage = null, aiExchange
@@ -115,6 +124,35 @@ function createApi({ network, live, analytics = null, aiUsage = null, aiExchange
return res.json(recaps);
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 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
// The public totals: the tokens, the cache's share, the own-key share and the spend by week. No person, no
@@ -25,6 +25,7 @@ const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('.
@@ -25,6 +25,7 @@ const { normaliseDays, isSlug, isStreamId, isUsername, streamIdOf } = require('.
const { twinPath } = require('../agent-pages');
const { twinPath } = require('../agent-pages');
const designViews = require('../views/design');
const designViews = require('../views/design');
const designHealth = require('../data/design-health');
const designHealth = require('../data/design-health');
+const sharing = require('../sharing');
const CACHE = 'public, max-age=120';
const CACHE = 'public, max-age=120';
@@ -160,15 +161,24 @@ function createPageRoutes(ctx) {
@@ -160,15 +161,24 @@ function createPageRoutes(ctx) {
return send(req, res, views.rundown({ rundown, topics, reports }));
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) => {
r.get('/s/:id', async (req, res) => {
const id = String(req.params.id || '');
const id = String(req.params.id || '');
const rep = sessionReports ? await sessionReports.get(id) : null;
const rep = sessionReports ? await sessionReports.get(id) : null;
if (!rep) return notFound(req, res);
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) => {
r.get('/rundown/t/:slug', async (req, res) => {
@@ -210,13 +220,17 @@ function createPageRoutes(ctx) {
@@ -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.
// 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 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 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,
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.channelRecaps(user.username)).catch(() => null),
Promise.resolve(live.speaker(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 && 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) => {
r.get('/c/:username', async (req, res) => {
@@ -111,6 +111,19 @@ function createSearchIndex({ config, store, live, sessionReports = null, log = c
@@ -111,6 +111,19 @@ function createSearchIndex({ config, store, live, sessionReports = null, log = c
});
});
const timers = [];
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() {
async function sweep() {
const res = await feed.sweep();
const res = await feed.sweep();
const r = res.topic;
const r = res.topic;
@@ -136,7 +149,12 @@ function createSearchIndex({ config, store, live, sessionReports = null, log = c
@@ -136,7 +149,12 @@ function createSearchIndex({ config, store, live, sessionReports = null, log = c
out.stop();
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 };
@@ -147,15 +147,58 @@ function rowToReport(row) {
@@ -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 db = store && store.db;
const base = String((config && config.baseUrl) || 'https://openvibe.report').replace(/\/+$/, '');
const base = String((config && config.baseUrl) || 'https://openvibe.report').replace(/\/+$/, '');
const urlFor = (id) => `${base}/s/${id}`;
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) {
async function write(body) {
if (!db) throw new Error('no database');
if (!db) throw new Error('no database');
const clean = cleanBody(body);
const clean = cleanBody(body);
+ const audience = await startingAudience(clean.owner, body && body.visibility);
+ clean.visibility = shares.visibilityFor(audience);
const id = 'rps_' + contracts.ids.ulid();
const id = 'rps_' + contracts.ids.ulid();
try {
try {
await db.prepare(`INSERT INTO report_sessions
await db.prepare(`INSERT INTO report_sessions
@@ -168,6 +211,9 @@ function createSessionReports({ store, config, log = console }) {
@@ -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.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.cost_usd, JSON.stringify(clean.models), JSON.stringify(clean.highlights),
clean.stream ? JSON.stringify(clean.stream) : null, clean.visibility);
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 };
return { id, url: urlFor(id), created: true };
} catch (err) {
} catch (err) {
const again = await db.prepare('SELECT id FROM report_sessions WHERE source = ? AND task_ref = ?')
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 }) {
@@ -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 [];
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);
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"). */
/** The public reports written today (the Rundown's "What agents built today"). */
async function listPublicToday({ limit = 24, now = () => new Date() } = {}) {
async function listPublicToday({ limit = 24, now = () => new Date() } = {}) {
const all = await listPublic({ limit: 200 });
const all = await listPublic({ limit: 200 });
@@ -237,6 +306,22 @@ function createSessionReports({ store, config, log = console }) {
@@ -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);
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
* 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.
* used. The tool's report keeps its id; the stream is added to it.
@@ -250,7 +335,10 @@ function createSessionReports({ store, config, log = console }) {
@@ -250,7 +335,10 @@ function createSessionReports({ store, config, log = console }) {
return id ? { id, url: urlFor(id) } : null;
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
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,238 @@
@@ -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; },
+};
@@ -366,7 +366,7 @@ function reportsSection(reports) {
@@ -366,7 +366,7 @@ function reportsSection(reports) {
+ '</section>';
+ '</section>';
}
}
-function sessionReport({ report: rep, signedIn = false } = {}) {
+function sessionReport({ report: rep, signedIn = false, isOwner = false, audience = 'private', members = [] } = {}) {
if (!rep) return notFound();
if (!rep) return notFound();
const out = reportOutcome(rep);
const out = reportOutcome(rep);
const proof = rep.proof || {};
const proof = rep.proof || {};
@@ -419,19 +419,37 @@ function sessionReport({ report: rep, signedIn = false } = {}) {
@@ -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('')
+ watch.map((o) => '<li><a href="' + (o.url ? esc(o.url) : '#') + '">' + esc(o.title) + '</a></li>').join('')
+ '</ul></section>';
+ '</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">'
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>' : '')
+ (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({
return layout({
title: rep.task.title + ': the session report',
title: rep.task.title + ': the session report',
description: rep.summary,
description: rep.summary,
body,
body,
active: '/rundown',
active: '/rundown',
path: '/s/' + rep.id,
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 } = {}) {
function topic({ topic: d } = {}) {
const t = d.topic || {};
const t = d.topic || {};
const takes = (d.takes || []).slice(0, 40);
const takes = (d.takes || []).slice(0, 40);
@@ -539,7 +557,7 @@ ${lines.slice(1).map((l) => `<p class="rp-fine">${esc(l)}</p>`).join('')}
@@ -539,7 +557,7 @@ ${lines.slice(1).map((l) => `<p class="rp-fine">${esc(l)}</p>`).join('')}
</section>`;
</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 name = String(user.display_name || user.username || 'you');
const username = String(user.username || '');
const username = String(user.username || '');
const T = (stats && stats.totals) || {};
const T = (stats && stats.totals) || {};
@@ -562,11 +580,32 @@ ${(sp.episodes || []).length ? `<p class="rp-meta">${plural(sp.episodes.length,
@@ -562,11 +580,32 @@ ${(sp.episodes || []).length ? `<p class="rp-meta">${plural(sp.episodes.length,
</section>` : '';
</section>` : '';
const body = `<header class="rp-page-h rp-person">${kit.mark({ name, src: user.avatar_url || '', size: 64 })}<div><p class="rp-kicker">Your numbers</p><h1>${esc(name)}</h1>
const body = `<header class="rp-page-h rp-person">${kit.mark({ name, src: user.avatar_url || '', size: 64 })}<div><p class="rp-kicker">Your numbers</p><h1>${esc(name)}</h1>
<p class="rp-lede rp-you-say">${esc(youSentence({ name, days, stats, speaker }))}</p><nav class="rp-tabs" aria-label="Time range">${tabs}</nav></div></header>
<p class="rp-lede rp-you-say">${esc(youSentence({ name, days, stats, speaker }))}</p><nav class="rp-tabs" aria-label="Time range">${tabs}</nav></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>`;
<section aria-labelledby="rp-you-next" class="rp-you-next"><h2 id="rp-you-next">Coming here</h2><p class="rp-fine">Your agents' work and what your computers ran. Only you see this page.</p></section>`;
return layout({ title: 'Your numbers', description: 'Your own numbers across OpenVibe: streams, money and The Rundown, in one place.', body, active: '/you', robots: 'noindex, nofollow' });
return layout({ title: 'Your numbers', description: 'Your own numbers across OpenVibe: streams, money and The Rundown, in one place.', body, active: '/you', robots: 'noindex, nofollow' });
}
}
+/**
+ * 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() {
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>
const body = `<header class="rp-page-h"><p class="rp-kicker">Your numbers</p><h1>Everything you do on OpenVibe, counted for you</h1>
<p class="rp-lede">Your streams and their after-show reports, the payments that reach you, The Rundown, and soon your AI use across every site. Only you see it.</p>
<p 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() {
@@ -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>` });
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 };
@@ -8,7 +8,7 @@ const assert = require('assert');
@@ -8,7 +8,7 @@ const assert = require('assert');
const { createNetworkClient } = require('../server/data/network');
const { createNetworkClient } = require('../server/data/network');
const { createLiveClient } = require('../server/data/live');
const { createLiveClient } = require('../server/data/live');
const { isSlug, isStreamId, isUsername, normaliseDays } = require('../server/data/source');
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() {} });
const quiet = () => ({ warn() {}, error() {} });
@@ -104,7 +104,7 @@ const quiet = () => ({ warn() {}, error() {} });
@@ -104,7 +104,7 @@ const quiet = () => ({ warn() {}, error() {} });
const urls = [];
const urls = [];
const client = createLiveClient({
const client = createLiveClient({
config: { liveUrl: 'http://live' },
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,
now: () => now,
log: quiet(),
log: quiet(),
});
});
@@ -114,10 +114,10 @@ const quiet = () => ({ warn() {}, error() {} });
@@ -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');
assert.strictEqual(urls.filter((u) => u.endsWith('/api/rundown/')).length, 2, 'the home is held for two minutes');
await client.recap(7);
await client.recap(7);
- now += 9 * 60_000;
+ now += 59_000;
await client.recap(7);
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);
await client.recap(7);
assert.strictEqual(urls.filter((u) => u.endsWith('/api/recap/7')).length, 2);
assert.strictEqual(urls.filter((u) => u.endsWith('/api/recap/7')).length, 2);
@@ -151,6 +151,72 @@ const quiet = () => ({ warn() {}, error() {} });
@@ -151,6 +151,72 @@ const quiet = () => ({ warn() {}, error() {} });
assert.strictEqual((await client.rundown()).n, 3, 'too old to serve: fetched while the visitor waits');
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 () => {
await check('the cache keeps 1000 entries and drops the oldest first', async () => {
let calls = 0;
let calls = 0;
const client = createLiveClient({
const client = createLiveClient({
@@ -27,6 +27,11 @@ function jsonResponse(body, status = 200) {
@@ -27,6 +27,11 @@ function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
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() {
function defaults() {
return {
return {
stats: {
stats: {
@@ -125,10 +130,12 @@ function stubFetch(data = {}, { health = { network: true, live: true } } = {}) {
@@ -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);
if (m) return answers.episode == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.episode);
m = /^\/api\/rundown\/s\/([^/]+)$/.exec(p);
m = /^\/api\/rundown\/s\/([^/]+)$/.exec(p);
if (m) return answers.speaker == null ? jsonResponse({ error: 'not found' }, 404) : jsonResponse(answers.speaker);
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);
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);
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);
return jsonResponse({ error: 'no stub for ' + p }, 404);
};
};
@@ -172,6 +179,7 @@ async function boot(opts = {}) {
@@ -172,6 +179,7 @@ async function boot(opts = {}) {
shadow: opts.shadow,
shadow: opts.shadow,
...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),
...(opts.aiUsage ? { aiUsage: opts.aiUsage } : {}),
...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),
...(opts.verifyServiceToken ? { verifyServiceToken: opts.verifyServiceToken } : {}),
+ ...(opts.shareNetwork ? { shareNetwork: opts.shareNetwork } : {}),
releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },
releaseEnv: { ...process.env, RELEASE_COMMIT: '0000000' },
});
});
const server = await new Promise((resolve) => {
const server = await new Promise((resolve) => {
@@ -221,4 +229,4 @@ function done() {
@@ -221,4 +229,4 @@ function done() {
process.exit(failures ? 1 : 0);
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,376 @@
@@ -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); });