openvibers/openvibe.network Commits
Network: Feed cards carry the view store's counts; feed_previews on|wifi|off (#873)
Changes
@@@ -106,7 +106,7 @@ person's username and picture (a service token, `identity.subject.resolve`).## Depends on--- `openvibe-contracts` v0.317.0 (registry manifests, capability checks), `openvibe-sdk` v0.150.0 (PostgreSQL++- `openvibe-contracts` v0.317.0 (registry manifests, capability checks), `openvibe-sdk` v0.158.0 (PostgreSQLand Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.223.1, pinned by release tarball- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`workflow), OpenVibe.Blog (the changelog feed it proxies)@@@ -1037,6 +1037,6 @@ MIT (`license` in [package.json](package.json)).<!-- versions:start -->- openvibe-contracts: v0.416.0--- openvibe-sdk: v0.152.0++- openvibe-sdk: v0.158.0- openvibe-shared: v4.223.1<!-- versions:end -->@@@ -7,7 +7,7 @@"port": 4000,"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)","contracts": "openvibe-contracts v0.416.0",-- "sdk": "openvibe-sdk v0.152.0",++ "sdk": "openvibe-sdk v0.158.0","shared": "openvibe-shared v4.223.1","capabilities": ["identity.subject.resolve",@@@ -23,7 +23,7 @@"jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.416.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.1","otpauth": "9.6.0","pg": "^8.23.0",@@@ -2695,9 +2695,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.152.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",-- "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==",++ "version": "0.158.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0",++ "integrity": "sha512-jxUORU4Dju8nTo+eU5yAsXxbJq0swJ2A4l9XheIcNSd3OpJkWM+MN6D8cgSGn55x3gw3v54W+kg5XAwkUI9Cmg==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -27,7 +27,7 @@"jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.416.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.1","otpauth": "9.6.0","pg": "^8.23.0",@@@ -406,13 +406,28 @@ router.get('/me', requireAuth, async (req, res) => {const db = await getDb(req);const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);await subjects.ensureUserSubject(db, req.user);++ // The shared menu reads feed_previews here: on | wifi | off, default on (server/preferences/feed-previews.js).++ const feedPreviews = await require('../preferences/feed-previews').read(db, req.user.id);res.json({user: { ...sanitizeUser(req.user), level: await require('../levels/store').total(db, req.user.id) },-- preferences: prefs || { theme_id: 'vibe' },++ preferences: { ...(prefs || { theme_id: 'vibe' }), feed_previews: feedPreviews },features: { qr_signin: require('./qr-signin').enabled(getConfig(req)) },});});++// ── Set the account-level Feed preview choice ────────────────++// PUT /api/auth/me/preferences { feed_previews: 'on' | 'wifi' | 'off' } — what the shared menu reads from GET /me.++// The value travels with the account (site_prefs), never in a cookie, and an old tab's unknown value is refused.++router.put('/me/preferences', requireAuth, async (req, res) => {++ const db = await getDb(req);++ const value = req.body && req.body.feed_previews;++ if (!require('../preferences/feed-previews').VALUES.includes(String(value))) {++ return res.status(400).json({ error: 'feed_previews must be on, wifi or off', code: 'preferences.feed_previews' });++ }++ const stored = await require('../preferences/feed-previews').write(db, req.user.sub || req.user.id, value);++ res.set('Cache-Control', 'private, no-store').json({ ok: true, feed_previews: stored });++});++// ── Update Profile ───────────────────────────────────────────router.put('/profile', requireAuth, async (req, res) => {const db = await getDb(req);@@@ -85,6 +85,18 @@ function createFeedApi({ db, config = {}, privateKey = null, issuer = null, publ});const actors = await actorsFor(db, read.rows);let cards = read.rows.map((r) => cardOf(r, actors.get(r.actor_subject) || null));++ // The one view store (Media): counts.views and counts.engaged for every card, batched, and only here —++ // a Media outage leaves the source's own counts. The source's like/comment counts are kept. The feed.item@1++ // schema has no ref field, so the ref is built beside the cards, never added to one.++ try {++ const held = cards.map((card, i) => ({++ counts: card.counts,++ ref: { service: read.rows[i].source_service, kind: read.rows[i].source_type, id: String(read.rows[i].source_id),++ ...(read.rows[i].actor_subject ? { creator: String(read.rows[i].actor_subject) } : {}) },++ }));++ await require('../views/counts').attachCounts(held);++ held.forEach((h, i) => { cards[i].counts = h.counts; });++ } catch { /* a card without a count */ }if (q) cards = cards.filter((c) => searchMatches(c, q));cards = foldCards(cards, now());cards = rankCards(cards, { followed: new Set(followed), affinity, now: now() });@@@ -675,9 +675,18 @@ async function createFrameService(db, config, analytics, { privateKey = null, is}const d = await getJson(u.href, auth ? { Authorization: auth, Accept: 'application/json' } : { Accept: 'application/json' });if (path === '/api/v1/feed/search' && (!d || !Array.isArray(d.items))) return res.status(503).send('{"error":"feed search unavailable"}');++ // A Feed item that names its source ({ source: {service,type,id} } or a ref) gets the one view store's++ // counts.views/counts.engaged from Media (server/views/counts.js); a card with no source, or a Media++ // outage, keeps the counts it had. Batched, and the edge cache below keeps the result.++ let items = d && Array.isArray(d.items) ? d.items : [];++ if (path === '/api/v1/feed' && items.length) {++ try { items = await require('../views/counts').attachCounts(items); } catch { /* a card without a count */ }++ }const body = JSON.stringify(path === '/api/v1/feed/examples'? (d && Array.isArray(d.examples) ? d : { examples: [] })-- : (d && Array.isArray(d.items) ? d : { items: [], next_cursor: null }));++ : path === '/api/v1/feed'++ ? (d ? { ...d, items } : { items: [], next_cursor: null })++ : (d && Array.isArray(d.items) ? d : { items: [], next_cursor: null }));if (!auth && d) {if (relayed.size >= 500) relayed.clear();relayed.set(key, { at: Date.now(), body });nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++++/**++ * The account-level Feed preview choice: `feed_previews: 'on' | 'wifi' | 'off'`, default 'on'.++ *++ * The shared menu (openvibe-shared ov-home.js watchPreview) reads it from GET /api/auth/me's++ * `preferences.feed_previews` (and the theme answer carries it too) and stops autoplaying a Feed preview on++ * 'off', or on a metered connection on 'wifi'. It is stored in user_preferences.site_prefs beside the labels++ * and the site switcher, so it follows the account to every site without a new column or migration.++ */++const VALUES = ['on', 'wifi', 'off'];++const DEFAULT = 'on';++++async function rawPrefs(db, userId) {++ try {++ const row = await db.prepare('SELECT site_prefs FROM user_preferences WHERE user_id = ?').get(userId);++ if (!row || !row.site_prefs) return {};++ const parsed = JSON.parse(row.site_prefs);++ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};++ } catch { return {}; }++}++++/** The person's choice, or 'on' when they never set one (a missing row, an old value, a broken JSON). */++async function read(db, userId) {++ const prefs = await rawPrefs(db, userId);++ return VALUES.includes(String(prefs.feed_previews)) ? String(prefs.feed_previews) : DEFAULT;++}++++/** Set the choice, keeping every other site_prefs key. Returns the value stored. */++async function write(db, userId, value) {++ const next = String(value || '');++ if (!VALUES.includes(next)) throw Object.assign(new Error('feed_previews must be on, wifi or off'), { status: 400 });++ const current = await rawPrefs(db, userId);++ await db.prepare(`INSERT INTO user_preferences (user_id, site_prefs, updated_at) VALUES (?, ?, ov_now())++ ON CONFLICT(user_id) DO UPDATE SET site_prefs = excluded.site_prefs, updated_at = ov_now()`)++ .run(userId, JSON.stringify({ ...current, feed_previews: next }));++ return next;++}++++module.exports = { read, write, VALUES, DEFAULT };@@@ -161,12 +161,15 @@ router.get('/me/active', (req, res, next) => {try { const d = await db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }const { display_at, theme_at } = await lookDates(db, userId);const dated = { ...(display_at ? { display_at } : {}) };-- if (override) return res.json({ theme_id: override.theme_id, custom_variables: varsOf(override.custom_variables), display, ...dated, override: true, service });-- if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null, override: false });++ // The shared menu also reads its Feed preview choice from here (server/preferences/feed-previews.js).++ const feed_previews = await require('../preferences/feed-previews').read(db, userId);++ const menu = { feed_previews };++ if (override) return res.json({ theme_id: override.theme_id, custom_variables: varsOf(override.custom_variables), display, ...dated, ...menu, override: true, service });++ if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null, override: false, ...menu });let custom = null;try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}-- res.json({ theme_id: prefs.theme_id, custom_variables: custom, display, ...dated, ...(theme_at ? { theme_at } : {}), override: false });++ res.json({ theme_id: prefs.theme_id, custom_variables: custom, display, ...dated, ...(theme_at ? { theme_at } : {}), ...menu, override: false });});// ── Display preferences — follow the account to every site ──nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++++/**++ * View counts for Feed cards, read from OpenVibe.Media's public GET /api/v1/views/counts.++ *++ * A Feed row names its source in one of two shapes:++ * { source: { service, type, id }, counts } the new cross-site Feed (source_service/source_type/source_id)++ * { ref: { service, kind, id }, counts } a client that already has the ref++ * One batched call per 50 cards, each item gets counts.views and counts.engaged (the source's own likes,++ * comments and watchers are kept). Media unreachable or a card without a ref leaves its counts as they were:++ * a Feed still renders.++ */++const config = require('../config');++++const MAX_REFS = 50;++const TTL_MS = 30_000;++const cache = new Map();++++function refOf(item) {++ if (!item || typeof item !== 'object') return null;++ const src = item.source && typeof item.source === 'object' ? item.source++ : (item.source_service ? { service: item.source_service, type: item.source_type, id: item.source_id } : null);++ const ref = item.ref && typeof item.ref === 'object' ? item.ref : src;++ if (!ref) return null;++ const service = String(ref.service || '');++ const kind = String(ref.kind || ref.type || '');++ const id = ref.id == null ? '' : String(ref.id);++ if (!/^[a-z][a-z0-9-]{1,39}$/.test(service) || !/^[a-z][a-z0-9_]{1,39}$/.test(kind) || !id) return null;++ return { service, kind, id };++}++++function mediaUrl() {++ const s = config.services && config.services.media;++ return String((s && s.internalUrl) || 'http://127.0.0.1:4100').replace(/\/+$/, '');++}++++async function fetchCounts(refs, { fetchImpl = globalThis.fetch, timeoutMs = 3000 } = {}) {++ const keys = refs.map((r) => `${r.service}:${r.kind}:${r.id}`);++ const key = keys.join('|');++ const kept = cache.get(key);++ if (kept && Date.now() - kept.at < TTL_MS) return kept.counts;++ const ctrl = new AbortController();++ const timer = setTimeout(() => ctrl.abort(), timeoutMs);++ try {++ const url = `${mediaUrl()}/api/v1/views/counts?refs=${encodeURIComponent(JSON.stringify(refs))}`;++ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: ctrl.signal });++ if (!res.ok) return null;++ const body = await res.json();++ const counts = body && body.counts && typeof body.counts === 'object' ? body.counts : null;++ if (counts) {++ if (cache.size >= 500) cache.clear();++ cache.set(key, { at: Date.now(), counts });++ }++ return counts;++ } catch { return null; } finally { clearTimeout(timer); }++}++++/** Attach views/engaged to each Feed item that names a source. → the same items. */++async function attachCounts(items, opts = {}) {++ if (!Array.isArray(items) || !items.length) return items;++ const unique = [...new Map(items.map((item) => refOf(item)).filter(Boolean).map((r) => [`${r.service}:${r.kind}:${r.id}`, r])).values()];++ const counts = {};++ for (let i = 0; i < unique.length; i += MAX_REFS) {++ const found = await fetchCounts(unique.slice(i, i + MAX_REFS), opts);++ if (found) Object.assign(counts, found);++ }++ for (const item of items) {++ const ref = refOf(item);++ if (!ref) continue;++ const found = counts[`${ref.service}:${ref.kind}:${ref.id}`];++ if (!found) continue;++ item.counts = { ...(item.counts && typeof item.counts === 'object' ? item.counts : {}), views: found.views, engaged: found.engaged };++ }++ return items;++}++++module.exports = { attachCounts, refOf, fetchCounts, _cache: cache };nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++// The Feed's view counts and the account's Feed preview choice.++// - attachCounts puts Media's counts.views/counts.engaged on a Feed card that names its source, keeps the++// card's own likes/comments, and leaves a card without a source (or a Media outage) as it was;++// - feed_previews is on | wifi | off, default on, stored with the account and readable from GET /api/auth/me++// and GET /api/themes/me/active (the shared menu reads it there).++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const { getDb } = require('../server/db/database');++++(async () => {++ const log = console.log; console.log = () => {};++ const db = getDb();++ console.log = log;++ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (71, 'nadia', 'x', 'user'), (72, 'omar', 'x', 'user') ON CONFLICT DO NOTHING").run();++++ // ── Feed counts ─────────────────────────────────────────────++ let asked = 0;++ const media = http.createServer((req, res) => {++ asked += 1;++ const refs = JSON.parse(new URL(req.url, 'http://x').searchParams.get('refs'));++ const counts = {};++ for (const r of refs) counts[`${r.service}:${r.kind}:${r.id}`] = { views: 7, engaged: 3, previews: 2, preview_share: 0.286, peak_concurrent: null };++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({ counts }));++ });++ await new Promise((r) => media.listen(0, '127.0.0.1', r));++ const config = require('../server/config');++ config.services.media = { internalUrl: `http://127.0.0.1:${media.address().port}` };++ const { attachCounts } = require('../server/views/counts');++ const items = [++ { id: 'p:1', counts: { likes: 5 }, source: { service: 'media', type: 'vod', id: '12' } },++ { id: 'p:2', ref: { service: 'live', kind: 'stream', id: '9' } },++ { id: 'p:3', counts: { views: 1 } },++ ];++ await attachCounts(items);++ assert.strictEqual(asked, 1, 'one batched read');++ assert.deepStrictEqual(items[0].counts, { likes: 5, views: 7, engaged: 3 }, 'the source wins on views and keeps likes');++ assert.deepStrictEqual(items[1].counts, { views: 7, engaged: 3 }, 'a client ref works too');++ assert.deepStrictEqual(items[2].counts, { views: 1 }, 'a card without a source is untouched');++ await new Promise((r) => media.close(r));++++ // A Media outage leaves the cards as they were; the cache must not hide the failure for a new batch.++ require('../server/views/counts')._cache.clear();++ const outage = [ { source: { service: 'media', type: 'clip', id: '77' }, counts: { views: 2 } } ];++ await attachCounts(outage);++ assert.deepStrictEqual(outage[0].counts, { views: 2 });++++ // ── The account's feed_previews choice ─────────────────────++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ const cfg = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++ const { signToken } = require('../server/auth/routes');++ const app = express();++ app.use(express.json());++ Object.assign(app.locals, { db, config: cfg, privateKey: keys.privateKey, publicKey: keys.publicKey });++ app.use('/api/auth', require('../server/auth/routes'));++ app.use('/api/themes', require('../server/themes/routes'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, cfg);++ const call = async (method, p, { body, as } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(as ? { authorization: `Bearer ${await token(as)}` } : {}) }, body: body ? JSON.stringify(body) : undefined })++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++++ let r = await call('GET', '/api/auth/me', { as: 71 });++ assert.strictEqual(r.body.preferences.feed_previews, 'on', 'the default is on');++ r = await call('PUT', '/api/auth/me/preferences', { as: 71, body: { feed_previews: 'off' } });++ assert.deepStrictEqual([r.status, r.body.feed_previews], [200, 'off']);++ r = await call('GET', '/api/auth/me', { as: 71 });++ assert.strictEqual(r.body.preferences.feed_previews, 'off', 'the menu reads it from /api/auth/me');++ r = await call('GET', '/api/themes/me/active', { as: 71 });++ assert.strictEqual(r.body.feed_previews, 'off', 'the theme answer carries it too');++ r = await call('PUT', '/api/auth/me/preferences', { as: 71, body: { feed_previews: 'sometimes' } });++ assert.strictEqual(r.status, 400, 'an unknown value is refused');++ r = await call('GET', '/api/auth/me', { as: 72 });++ assert.strictEqual(r.body.preferences.feed_previews, 'on', 'another account keeps its own default');++ assert.strictEqual((await call('PUT', '/api/auth/me/preferences', { body: { feed_previews: 'off' } })).status, 401, 'a signed-out write is refused');++++ server.close();++ console.log('feed views + feed_previews: ok');++})().catch((error) => { console.error(error); process.exit(1); });@@@ -137,6 +137,7 @@ const REVIEWED = {'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],++ 'server/views/counts.js fetch': [1, 'Media\'s internal URL from config.services.media (operator config, loopback default), the fixed public /api/v1/views/counts path, refs JSON-encoded after refOf validates service/kind/id, 3 s timeout; no request value names a host'],'server/frame/mentions.js fetch': [1, 'the composer\'s @ menu: Live (OV_LIVE_INTERNAL_URL / config.services.live) from operator config, the fixed /api/streams public list path, no request value names a host, redirects not followed, 2.5 s timeout; only public rows are read and no token is sent'],'server/frame/service.js fetch': [8, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s); the AG-UI run stream relay uses a fixed /api/v1/runs/:id/events path, a validated task id, the trusted service URL, the person\'s Bearer, and no redirects'],'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
@@ -106,7 +106,7 @@ person's username and picture (a service token, `identity.subject.resolve`).
@@ -106,7 +106,7 @@ person's username and picture (a service token, `identity.subject.resolve`).
## Depends on
## Depends on
-- `openvibe-contracts` v0.317.0 (registry manifests, capability checks), `openvibe-sdk` v0.150.0 (PostgreSQL
+- `openvibe-contracts` v0.317.0 (registry manifests, capability checks), `openvibe-sdk` v0.158.0 (PostgreSQL
and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.223.1, pinned by release tarball
and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.223.1, pinned by release tarball
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
workflow), OpenVibe.Blog (the changelog feed it proxies)
workflow), OpenVibe.Blog (the changelog feed it proxies)
@@ -1037,6 +1037,6 @@ MIT (`license` in [package.json](package.json)).
@@ -1037,6 +1037,6 @@ MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.416.0
- openvibe-contracts: v0.416.0
-- openvibe-sdk: v0.152.0
+- openvibe-sdk: v0.158.0
- openvibe-shared: v4.223.1
- openvibe-shared: v4.223.1
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"contracts": "openvibe-contracts v0.416.0",
"contracts": "openvibe-contracts v0.416.0",
- "sdk": "openvibe-sdk v0.152.0",
+ "sdk": "openvibe-sdk v0.158.0",
"shared": "openvibe-shared v4.223.1",
"shared": "openvibe-shared v4.223.1",
"capabilities": [
"capabilities": [
"identity.subject.resolve",
"identity.subject.resolve",
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.416.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.416.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.1",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2695,9 +2695,9 @@
@@ -2695,9 +2695,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.152.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
- "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==",
+ "version": "0.158.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0",
+ "integrity": "sha512-jxUORU4Dju8nTo+eU5yAsXxbJq0swJ2A4l9XheIcNSd3OpJkWM+MN6D8cgSGn55x3gw3v54W+kg5XAwkUI9Cmg==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.416.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.416.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.223.1",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -406,13 +406,28 @@ router.get('/me', requireAuth, async (req, res) => {
@@ -406,13 +406,28 @@ router.get('/me', requireAuth, async (req, res) => {
const db = await getDb(req);
const db = await getDb(req);
const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);
const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(req.user.id);
await subjects.ensureUserSubject(db, req.user);
await subjects.ensureUserSubject(db, req.user);
+ // The shared menu reads feed_previews here: on | wifi | off, default on (server/preferences/feed-previews.js).
+ const feedPreviews = await require('../preferences/feed-previews').read(db, req.user.id);
res.json({
res.json({
user: { ...sanitizeUser(req.user), level: await require('../levels/store').total(db, req.user.id) },
user: { ...sanitizeUser(req.user), level: await require('../levels/store').total(db, req.user.id) },
- preferences: prefs || { theme_id: 'vibe' },
+ preferences: { ...(prefs || { theme_id: 'vibe' }), feed_previews: feedPreviews },
features: { qr_signin: require('./qr-signin').enabled(getConfig(req)) },
features: { qr_signin: require('./qr-signin').enabled(getConfig(req)) },
});
});
});
});
+// ── Set the account-level Feed preview choice ────────────────
+// PUT /api/auth/me/preferences { feed_previews: 'on' | 'wifi' | 'off' } — what the shared menu reads from GET /me.
+// The value travels with the account (site_prefs), never in a cookie, and an old tab's unknown value is refused.
+router.put('/me/preferences', requireAuth, async (req, res) => {
+ const db = await getDb(req);
+ const value = req.body && req.body.feed_previews;
+ if (!require('../preferences/feed-previews').VALUES.includes(String(value))) {
+ return res.status(400).json({ error: 'feed_previews must be on, wifi or off', code: 'preferences.feed_previews' });
+ }
+ const stored = await require('../preferences/feed-previews').write(db, req.user.sub || req.user.id, value);
+ res.set('Cache-Control', 'private, no-store').json({ ok: true, feed_previews: stored });
+});
+
// ── Update Profile ───────────────────────────────────────────
// ── Update Profile ───────────────────────────────────────────
router.put('/profile', requireAuth, async (req, res) => {
router.put('/profile', requireAuth, async (req, res) => {
const db = await getDb(req);
const db = await getDb(req);
@@ -85,6 +85,18 @@ function createFeedApi({ db, config = {}, privateKey = null, issuer = null, publ
@@ -85,6 +85,18 @@ function createFeedApi({ db, config = {}, privateKey = null, issuer = null, publ
});
});
const actors = await actorsFor(db, read.rows);
const actors = await actorsFor(db, read.rows);
let cards = read.rows.map((r) => cardOf(r, actors.get(r.actor_subject) || null));
let cards = read.rows.map((r) => cardOf(r, actors.get(r.actor_subject) || null));
+ // The one view store (Media): counts.views and counts.engaged for every card, batched, and only here —
+ // a Media outage leaves the source's own counts. The source's like/comment counts are kept. The feed.item@1
+ // schema has no ref field, so the ref is built beside the cards, never added to one.
+ try {
+ const held = cards.map((card, i) => ({
+ counts: card.counts,
+ ref: { service: read.rows[i].source_service, kind: read.rows[i].source_type, id: String(read.rows[i].source_id),
+ ...(read.rows[i].actor_subject ? { creator: String(read.rows[i].actor_subject) } : {}) },
+ }));
+ await require('../views/counts').attachCounts(held);
+ held.forEach((h, i) => { cards[i].counts = h.counts; });
+ } catch { /* a card without a count */ }
if (q) cards = cards.filter((c) => searchMatches(c, q));
if (q) cards = cards.filter((c) => searchMatches(c, q));
cards = foldCards(cards, now());
cards = foldCards(cards, now());
cards = rankCards(cards, { followed: new Set(followed), affinity, now: now() });
cards = rankCards(cards, { followed: new Set(followed), affinity, now: now() });
@@ -675,9 +675,18 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -675,9 +675,18 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
}
}
const d = await getJson(u.href, auth ? { Authorization: auth, Accept: 'application/json' } : { Accept: 'application/json' });
const d = await getJson(u.href, auth ? { Authorization: auth, Accept: 'application/json' } : { Accept: 'application/json' });
if (path === '/api/v1/feed/search' && (!d || !Array.isArray(d.items))) return res.status(503).send('{"error":"feed search unavailable"}');
if (path === '/api/v1/feed/search' && (!d || !Array.isArray(d.items))) return res.status(503).send('{"error":"feed search unavailable"}');
+ // A Feed item that names its source ({ source: {service,type,id} } or a ref) gets the one view store's
+ // counts.views/counts.engaged from Media (server/views/counts.js); a card with no source, or a Media
+ // outage, keeps the counts it had. Batched, and the edge cache below keeps the result.
+ let items = d && Array.isArray(d.items) ? d.items : [];
+ if (path === '/api/v1/feed' && items.length) {
+ try { items = await require('../views/counts').attachCounts(items); } catch { /* a card without a count */ }
+ }
const body = JSON.stringify(path === '/api/v1/feed/examples'
const body = JSON.stringify(path === '/api/v1/feed/examples'
? (d && Array.isArray(d.examples) ? d : { examples: [] })
? (d && Array.isArray(d.examples) ? d : { examples: [] })
- : (d && Array.isArray(d.items) ? d : { items: [], next_cursor: null }));
+ : path === '/api/v1/feed'
+ ? (d ? { ...d, items } : { items: [], next_cursor: null })
+ : (d && Array.isArray(d.items) ? d : { items: [], next_cursor: null }));
if (!auth && d) {
if (!auth && d) {
if (relayed.size >= 500) relayed.clear();
if (relayed.size >= 500) relayed.clear();
relayed.set(key, { at: Date.now(), body });
relayed.set(key, { at: Date.now(), body });
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+
+/**
+ * The account-level Feed preview choice: `feed_previews: 'on' | 'wifi' | 'off'`, default 'on'.
+ *
+ * The shared menu (openvibe-shared ov-home.js watchPreview) reads it from GET /api/auth/me's
+ * `preferences.feed_previews` (and the theme answer carries it too) and stops autoplaying a Feed preview on
+ * 'off', or on a metered connection on 'wifi'. It is stored in user_preferences.site_prefs beside the labels
+ * and the site switcher, so it follows the account to every site without a new column or migration.
+ */
+const VALUES = ['on', 'wifi', 'off'];
+const DEFAULT = 'on';
+
+async function rawPrefs(db, userId) {
+ try {
+ const row = await db.prepare('SELECT site_prefs FROM user_preferences WHERE user_id = ?').get(userId);
+ if (!row || !row.site_prefs) return {};
+ const parsed = JSON.parse(row.site_prefs);
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
+ } catch { return {}; }
+}
+
+/** The person's choice, or 'on' when they never set one (a missing row, an old value, a broken JSON). */
+async function read(db, userId) {
+ const prefs = await rawPrefs(db, userId);
+ return VALUES.includes(String(prefs.feed_previews)) ? String(prefs.feed_previews) : DEFAULT;
+}
+
+/** Set the choice, keeping every other site_prefs key. Returns the value stored. */
+async function write(db, userId, value) {
+ const next = String(value || '');
+ if (!VALUES.includes(next)) throw Object.assign(new Error('feed_previews must be on, wifi or off'), { status: 400 });
+ const current = await rawPrefs(db, userId);
+ await db.prepare(`INSERT INTO user_preferences (user_id, site_prefs, updated_at) VALUES (?, ?, ov_now())
+ ON CONFLICT(user_id) DO UPDATE SET site_prefs = excluded.site_prefs, updated_at = ov_now()`)
+ .run(userId, JSON.stringify({ ...current, feed_previews: next }));
+ return next;
+}
+
+module.exports = { read, write, VALUES, DEFAULT };
@@ -161,12 +161,15 @@ router.get('/me/active', (req, res, next) => {
@@ -161,12 +161,15 @@ router.get('/me/active', (req, res, next) => {
try { const d = await db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }
try { const d = await db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }
const { display_at, theme_at } = await lookDates(db, userId);
const { display_at, theme_at } = await lookDates(db, userId);
const dated = { ...(display_at ? { display_at } : {}) };
const dated = { ...(display_at ? { display_at } : {}) };
- if (override) return res.json({ theme_id: override.theme_id, custom_variables: varsOf(override.custom_variables), display, ...dated, override: true, service });
- if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null, override: false });
+ // The shared menu also reads its Feed preview choice from here (server/preferences/feed-previews.js).
+ const feed_previews = await require('../preferences/feed-previews').read(db, userId);
+ const menu = { feed_previews };
+ if (override) return res.json({ theme_id: override.theme_id, custom_variables: varsOf(override.custom_variables), display, ...dated, ...menu, override: true, service });
+ if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null, override: false, ...menu });
let custom = null;
let custom = null;
try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
- res.json({ theme_id: prefs.theme_id, custom_variables: custom, display, ...dated, ...(theme_at ? { theme_at } : {}), override: false });
+ res.json({ theme_id: prefs.theme_id, custom_variables: custom, display, ...dated, ...(theme_at ? { theme_at } : {}), ...menu, override: false });
});
});
// ── Display preferences — follow the account to every site ──
// ── Display preferences — follow the account to every site ──
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * View counts for Feed cards, read from OpenVibe.Media's public GET /api/v1/views/counts.
+ *
+ * A Feed row names its source in one of two shapes:
+ * { source: { service, type, id }, counts } the new cross-site Feed (source_service/source_type/source_id)
+ * { ref: { service, kind, id }, counts } a client that already has the ref
+ * One batched call per 50 cards, each item gets counts.views and counts.engaged (the source's own likes,
+ * comments and watchers are kept). Media unreachable or a card without a ref leaves its counts as they were:
+ * a Feed still renders.
+ */
+const config = require('../config');
+
+const MAX_REFS = 50;
+const TTL_MS = 30_000;
+const cache = new Map();
+
+function refOf(item) {
+ if (!item || typeof item !== 'object') return null;
+ const src = item.source && typeof item.source === 'object' ? item.source
+ : (item.source_service ? { service: item.source_service, type: item.source_type, id: item.source_id } : null);
+ const ref = item.ref && typeof item.ref === 'object' ? item.ref : src;
+ if (!ref) return null;
+ const service = String(ref.service || '');
+ const kind = String(ref.kind || ref.type || '');
+ const id = ref.id == null ? '' : String(ref.id);
+ if (!/^[a-z][a-z0-9-]{1,39}$/.test(service) || !/^[a-z][a-z0-9_]{1,39}$/.test(kind) || !id) return null;
+ return { service, kind, id };
+}
+
+function mediaUrl() {
+ const s = config.services && config.services.media;
+ return String((s && s.internalUrl) || 'http://127.0.0.1:4100').replace(/\/+$/, '');
+}
+
+async function fetchCounts(refs, { fetchImpl = globalThis.fetch, timeoutMs = 3000 } = {}) {
+ const keys = refs.map((r) => `${r.service}:${r.kind}:${r.id}`);
+ const key = keys.join('|');
+ const kept = cache.get(key);
+ if (kept && Date.now() - kept.at < TTL_MS) return kept.counts;
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
+ try {
+ const url = `${mediaUrl()}/api/v1/views/counts?refs=${encodeURIComponent(JSON.stringify(refs))}`;
+ const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: ctrl.signal });
+ if (!res.ok) return null;
+ const body = await res.json();
+ const counts = body && body.counts && typeof body.counts === 'object' ? body.counts : null;
+ if (counts) {
+ if (cache.size >= 500) cache.clear();
+ cache.set(key, { at: Date.now(), counts });
+ }
+ return counts;
+ } catch { return null; } finally { clearTimeout(timer); }
+}
+
+/** Attach views/engaged to each Feed item that names a source. → the same items. */
+async function attachCounts(items, opts = {}) {
+ if (!Array.isArray(items) || !items.length) return items;
+ const unique = [...new Map(items.map((item) => refOf(item)).filter(Boolean).map((r) => [`${r.service}:${r.kind}:${r.id}`, r])).values()];
+ const counts = {};
+ for (let i = 0; i < unique.length; i += MAX_REFS) {
+ const found = await fetchCounts(unique.slice(i, i + MAX_REFS), opts);
+ if (found) Object.assign(counts, found);
+ }
+ for (const item of items) {
+ const ref = refOf(item);
+ if (!ref) continue;
+ const found = counts[`${ref.service}:${ref.kind}:${ref.id}`];
+ if (!found) continue;
+ item.counts = { ...(item.counts && typeof item.counts === 'object' ? item.counts : {}), views: found.views, engaged: found.engaged };
+ }
+ return items;
+}
+
+module.exports = { attachCounts, refOf, fetchCounts, _cache: cache };
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+// The Feed's view counts and the account's Feed preview choice.
+// - attachCounts puts Media's counts.views/counts.engaged on a Feed card that names its source, keeps the
+// card's own likes/comments, and leaves a card without a source (or a Media outage) as it was;
+// - feed_previews is on | wifi | off, default on, stored with the account and readable from GET /api/auth/me
+// and GET /api/themes/me/active (the shared menu reads it there).
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+
+(async () => {
+ const log = console.log; console.log = () => {};
+ const db = getDb();
+ console.log = log;
+ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (71, 'nadia', 'x', 'user'), (72, 'omar', 'x', 'user') ON CONFLICT DO NOTHING").run();
+
+ // ── Feed counts ─────────────────────────────────────────────
+ let asked = 0;
+ const media = http.createServer((req, res) => {
+ asked += 1;
+ const refs = JSON.parse(new URL(req.url, 'http://x').searchParams.get('refs'));
+ const counts = {};
+ for (const r of refs) counts[`${r.service}:${r.kind}:${r.id}`] = { views: 7, engaged: 3, previews: 2, preview_share: 0.286, peak_concurrent: null };
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({ counts }));
+ });
+ await new Promise((r) => media.listen(0, '127.0.0.1', r));
+ const config = require('../server/config');
+ config.services.media = { internalUrl: `http://127.0.0.1:${media.address().port}` };
+ const { attachCounts } = require('../server/views/counts');
+ const items = [
+ { id: 'p:1', counts: { likes: 5 }, source: { service: 'media', type: 'vod', id: '12' } },
+ { id: 'p:2', ref: { service: 'live', kind: 'stream', id: '9' } },
+ { id: 'p:3', counts: { views: 1 } },
+ ];
+ await attachCounts(items);
+ assert.strictEqual(asked, 1, 'one batched read');
+ assert.deepStrictEqual(items[0].counts, { likes: 5, views: 7, engaged: 3 }, 'the source wins on views and keeps likes');
+ assert.deepStrictEqual(items[1].counts, { views: 7, engaged: 3 }, 'a client ref works too');
+ assert.deepStrictEqual(items[2].counts, { views: 1 }, 'a card without a source is untouched');
+ await new Promise((r) => media.close(r));
+
+ // A Media outage leaves the cards as they were; the cache must not hide the failure for a new batch.
+ require('../server/views/counts')._cache.clear();
+ const outage = [ { source: { service: 'media', type: 'clip', id: '77' }, counts: { views: 2 } } ];
+ await attachCounts(outage);
+ assert.deepStrictEqual(outage[0].counts, { views: 2 });
+
+ // ── The account's feed_previews choice ─────────────────────
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ const cfg = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+ const { signToken } = require('../server/auth/routes');
+ const app = express();
+ app.use(express.json());
+ Object.assign(app.locals, { db, config: cfg, privateKey: keys.privateKey, publicKey: keys.publicKey });
+ app.use('/api/auth', require('../server/auth/routes'));
+ app.use('/api/themes', require('../server/themes/routes'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, cfg);
+ const call = async (method, p, { body, as } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(as ? { authorization: `Bearer ${await token(as)}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
+ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+
+ let r = await call('GET', '/api/auth/me', { as: 71 });
+ assert.strictEqual(r.body.preferences.feed_previews, 'on', 'the default is on');
+ r = await call('PUT', '/api/auth/me/preferences', { as: 71, body: { feed_previews: 'off' } });
+ assert.deepStrictEqual([r.status, r.body.feed_previews], [200, 'off']);
+ r = await call('GET', '/api/auth/me', { as: 71 });
+ assert.strictEqual(r.body.preferences.feed_previews, 'off', 'the menu reads it from /api/auth/me');
+ r = await call('GET', '/api/themes/me/active', { as: 71 });
+ assert.strictEqual(r.body.feed_previews, 'off', 'the theme answer carries it too');
+ r = await call('PUT', '/api/auth/me/preferences', { as: 71, body: { feed_previews: 'sometimes' } });
+ assert.strictEqual(r.status, 400, 'an unknown value is refused');
+ r = await call('GET', '/api/auth/me', { as: 72 });
+ assert.strictEqual(r.body.preferences.feed_previews, 'on', 'another account keeps its own default');
+ assert.strictEqual((await call('PUT', '/api/auth/me/preferences', { body: { feed_previews: 'off' } })).status, 401, 'a signed-out write is refused');
+
+ server.close();
+ console.log('feed views + feed_previews: ok');
+})().catch((error) => { console.error(error); process.exit(1); });
@@ -137,6 +137,7 @@ const REVIEWED = {
@@ -137,6 +137,7 @@ const REVIEWED = {
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
+ 'server/views/counts.js fetch': [1, 'Media\'s internal URL from config.services.media (operator config, loopback default), the fixed public /api/v1/views/counts path, refs JSON-encoded after refOf validates service/kind/id, 3 s timeout; no request value names a host'],
'server/frame/mentions.js fetch': [1, 'the composer\'s @ menu: Live (OV_LIVE_INTERNAL_URL / config.services.live) from operator config, the fixed /api/streams public list path, no request value names a host, redirects not followed, 2.5 s timeout; only public rows are read and no token is sent'],
'server/frame/mentions.js fetch': [1, 'the composer\'s @ menu: Live (OV_LIVE_INTERNAL_URL / config.services.live) from operator config, the fixed /api/streams public list path, no request value names a host, redirects not followed, 2.5 s timeout; only public rows are read and no token is sent'],
'server/frame/service.js fetch': [8, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s); the AG-UI run stream relay uses a fixed /api/v1/runs/:id/events path, a validated task id, the trusted service URL, the person\'s Bearer, and no redirects'],
'server/frame/service.js fetch': [8, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s); the AG-UI run stream relay uses a fixed /api/v1/runs/:id/events path, a validated task id, the trusted service URL, the person\'s Bearer, and no redirects'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],