openvibers/openvibe.network Commits

Network: Feed cards carry the view store's counts; feed_previews on|wifi|off (#873)

OP OpenVibers committed 22 h ago · c5860a6524 · 12 files, +253 −13

* Network: Feed cards carry views and engaged viewers; feed_previews on|wifi|off

* Merge origin/main into views-network; pin contracts 0.416.0 and sdk 0.158.0

* Docs: name contracts 0.416.0 and sdk 0.158.0

* Network: trigger the checks

* Network: review the view-store outbound call site (security-ssrf)

Changes

ddiff --git a/README.md b/README.md
iindex 94a063df..b745c00a 100644
---- a/README.md
++++ b/README.md
@@@ -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 (PostgreSQL
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`
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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex c0018c01..b733a357 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package-lock.json b/package-lock.json
iindex f900a74e..fe51bc90 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 6d23315b..761bd049 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex f29a1023..0677e6a5 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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);
ddiff --git a/server/feed/api.js b/server/feed/api.js
iindex 785f3f10..4dde0021 100644
---- a/server/feed/api.js
++++ b/server/feed/api.js
@@@ -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() });
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex c521b8d0..f26e50fd 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -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 });
ddiff --git a/server/preferences/feed-previews.js b/server/preferences/feed-previews.js
nnew file mode 100644
iindex 00000000..3d098277
---- /dev/null
++++ b/server/preferences/feed-previews.js
@@@ -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 };
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex 31c1326b..8dd953d4 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -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 ──
ddiff --git a/server/views/counts.js b/server/views/counts.js
nnew file mode 100644
iindex 00000000..2651ae32
---- /dev/null
++++ b/server/views/counts.js
@@@ -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 };
ddiff --git a/test/feed-views-prefs.test.js b/test/feed-views-prefs.test.js
nnew file mode 100644
iindex 00000000..7883d6fb
---- /dev/null
++++ b/test/feed-views-prefs.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 95109c56..4a7ebb39 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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)'],