openvibers/openvibe.network Commits

The composer's settings and @ menu (composer.prefs@1, composer.mention@1) (#806)

OP OpenVibers committed 2 days ago · 6f5a0c6093 · 8 files, +355 −2

* The composer's settings and @ menu on Network (composer.prefs@1, composer.mention@1)

* Merge origin/main into composer-network

* Review the composer's Git call in the SSRF inventory

* Merge origin/main into composer-network

Changes

ddiff --git a/migrations/0072_composer_prefs.sql b/migrations/0072_composer_prefs.sql
nnew file mode 100644
iindex 00000000..bfbb71b4
---- /dev/null
++++ b/migrations/0072_composer_prefs.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- The composer's settings (composer.prefs@1): one document per person, the same on every OpenVibe site. The revision
++-- is how two tabs agree not to overwrite each other: a PUT names the revision it read, and a moved revision is a 409
++-- with the current copy. Additive: a release from before this one never reads the columns.
++ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS composer_prefs text COLLATE "C";
++ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS composer_rev bigint NOT NULL DEFAULT 0;
ddiff --git a/server/composer/routes.js b/server/composer/routes.js
nnew file mode 100644
iindex 00000000..14aa629f
---- /dev/null
++++ b/server/composer/routes.js
@@@ -0,0 +1,183 @@
++'use strict';
++/**
++ * The shared composer's server half on OpenVibe.Network.
++ *
++ * GET /api/v1/me/composer composer.prefs@1 (rev + the person's settings), signed-in only
++ * PUT /api/v1/me/composer composer.prefs@1 in, the new document out; a stale `rev` is 409 with `current`
++ * GET /api/v1/mentions composer.mention@1 entries: people the person follows first, then public
++ * people; repositories from OpenVibe Git the person can see; first-party sites.
++ * Signed-in only, at most 8 entries.
++ *
++ * The CORS allow-list is the app-wide one (server/public-cors.js: every first-party manifest origin; a foreign
++ * origin is refused with 403 before the route). The bytes a post attaches come from Media, not here.
++ *
++ * composer.prefs@1 is normalised here rather than through `contracts.validate`, so Network keeps working with the
++ * contracts release it pins; the shape is the contract's, field for field.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++
++const MAX_MENTIONS = 8;
++const Q_MAX = 100;
++const SEND_KEYS = ['enter', 'mod-enter'];
++const DENSITIES = ['comfy', 'compact'];
++const TRIGGER_RE = /^\/[a-z0-9][a-z0-9_-]{0,31}$/;
++const TOOL_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
++const KINDS = ['person', 'repo', 'site', 'file'];
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++const DEFAULTS = Object.freeze({ send_key: 'enter', tools: [], density: 'comfy', voice: true, snippets: [] });
++const clone = (v) => JSON.parse(JSON.stringify(v));
++
++/** The stored document, or the defaults at rev 0. */
++function parseRow(row) {
++ if (!row) return { rev: 0, ...clone(DEFAULTS) };
++ let prefs = null;
++ try { prefs = row.composer_prefs ? JSON.parse(row.composer_prefs) : null; } catch { prefs = null; }
++ const rev = Math.max(0, Number(row.composer_rev) || 0);
++ return { rev, ...clone(DEFAULTS), ...(prefs && typeof prefs === 'object' ? prefs : {}) };
++}
++
++/** Normalise a PUT body (composer.prefs@1) without its rev. -> { value } or { error }. */
++function cleanPrefs(body) {
++ if (!body || typeof body !== 'object' || Array.isArray(body)) return { error: 'the body must be a composer.prefs@1 object' };
++ const known = new Set(['rev', 'send_key', 'tools', 'density', 'voice', 'snippets']);
++ for (const k of Object.keys(body)) if (!known.has(k)) return { error: `unknown field ${k}` };
++ if (!Number.isInteger(body.rev) || body.rev < 0) return { error: 'rev must be an integer >= 0' };
++ if (!SEND_KEYS.includes(body.send_key)) return { error: `send_key must be one of ${SEND_KEYS.join(', ')}` };
++ if (!DENSITIES.includes(body.density)) return { error: `density must be one of ${DENSITIES.join(', ')}` };
++ if (typeof body.voice !== 'boolean') return { error: 'voice must be a boolean' };
++ if (!Array.isArray(body.tools) || body.tools.length > 100) return { error: 'tools must be an array of at most 100 entries' };
++ const tools = [];
++ for (const t of body.tools) {
++ if (!t || typeof t !== 'object' || Array.isArray(t)) return { error: 'each tool must be an object' };
++ for (const k of Object.keys(t)) if (!['id', 'hidden'].includes(k)) return { error: `unknown tool field ${k}` };
++ if (typeof t.id !== 'string' || !TOOL_ID_RE.test(t.id) || t.id.length > 40) return { error: 'each tool needs an id of lower-case letters, digits, . _ -' };
++ if (typeof t.hidden !== 'boolean') return { error: 'each tool needs hidden: true or false' };
++ tools.push({ id: t.id, hidden: t.hidden });
++ }
++ if (!Array.isArray(body.snippets) || body.snippets.length > 50) return { error: 'snippets must be an array of at most 50 entries' };
++ const snippets = [];
++ for (const s of body.snippets) {
++ if (!s || typeof s !== 'object' || Array.isArray(s)) return { error: 'each snippet must be an object' };
++ for (const k of Object.keys(s)) if (!['trigger', 'label', 'text'].includes(k)) return { error: `unknown snippet field ${k}` };
++ if (typeof s.trigger !== 'string' || !TRIGGER_RE.test(s.trigger)) return { error: 'a trigger is a slash word such as /sig' };
++ if (typeof s.label !== 'string' || !s.label.length || s.label.length > 80) return { error: 'a snippet label is 1 to 80 characters' };
++ if (typeof s.text !== 'string' || !s.text.length || s.text.length > 4000) return { error: 'a snippet text is 1 to 4000 characters' };
++ snippets.push({ trigger: s.trigger, label: s.label, text: s.text });
++ }
++ return { value: { send_key: body.send_key, tools, density: body.density, voice: body.voice, snippets } };
++}
++
++function createRouter({ db, requireAuth, selfToken = () => null, config, fetchImpl = (...a) => fetch(...a), log = console } = {}) {
++ if (!db || !requireAuth) throw new TypeError('createRouter needs db and requireAuth');
++ const router = express.Router();
++ const handle = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
++ const base = String((config && config.baseUrl) || 'https://openvibe.network').replace(/\/+$/, '');
++ const avatarOf = (u) => {
++ const a = u && u.avatar_url ? String(u.avatar_url) : '';
++ if (!a) return `${base}/avatar/${encodeURIComponent(u.username)}?s=64`;
++ return /^https?:\/\//.test(a) ? a : `${base}${a.startsWith('/') ? '' : '/'}${a}`;
++ };
++ const mentionPerson = (u) => ({
++ kind: 'person',
++ id: u.subject_id,
++ label: u.display_name || u.username,
++ handle: u.username,
++ avatar: avatarOf(u),
++ url: `${base}/@${encodeURIComponent(u.username)}`,
++ });
++ const storedRow = (userId) => db.prepare('SELECT composer_rev, composer_prefs FROM user_preferences WHERE user_id = ?').get(userId);
++ const conflict = (res, current) => res.status(409).json({ status: 409, error: 'the composer settings changed in another tab', code: 'composer.rev_conflict', current });
++
++ // ── The person's composer settings, revision-checked ─────
++ router.get('/me/composer', requireAuth, handle(async (req, res) => {
++ res.set('Cache-Control', 'no-store').json(parseRow(await storedRow(req.user.id)));
++ }));
++
++ router.put('/me/composer', requireAuth, handle(async (req, res) => {
++ const userId = req.user.id;
++ const cleaned = cleanPrefs(req.body);
++ if (cleaned.error) return res.status(422).json({ error: cleaned.error, code: 'composer.invalid_prefs' });
++ const row = await storedRow(userId);
++ const current = parseRow(row);
++ if (req.body.rev !== current.rev) return conflict(res, current);
++ const next = { rev: current.rev + 1, ...cleaned.value };
++ const json = JSON.stringify(cleaned.value);
++ if (!row) {
++ try {
++ await db.prepare('INSERT INTO user_preferences (user_id, composer_prefs, composer_rev, updated_at) VALUES (?, ?, ?, ov_now())').run(userId, json, next.rev);
++ } catch {
++ return conflict(res, parseRow(await storedRow(userId))); // another tab inserted first
++ }
++ } else {
++ const r = await db.prepare('UPDATE user_preferences SET composer_prefs = ?, composer_rev = ?, updated_at = ov_now() WHERE user_id = ? AND composer_rev = ?').run(json, next.rev, userId, current.rev);
++ if (!r.changes) return conflict(res, parseRow(await storedRow(userId)));
++ }
++ res.set('Cache-Control', 'no-store').json(next);
++ }));
++
++ // ── The @ menu ────────────────────────────────────────────
++ router.get('/mentions', requireAuth, handle(async (req, res) => {
++ const q = String(req.query.q || '').trim().slice(0, Q_MAX);
++ const asked = String(req.query.kinds || '').split(',').map((k) => k.trim()).filter(Boolean);
++ const kinds = asked.length ? asked.filter((k) => KINDS.includes(k)) : KINDS.slice();
++ if (!kinds.length) return res.status(422).json({ error: `kinds must be among ${KINDS.join(', ')}`, code: 'mentions.invalid_kinds' });
++ const out = [];
++ const room = () => MAX_MENTIONS - out.length;
++
++ if (kinds.includes('person') && room() > 0) {
++ const params = [req.user.id, req.user.id];
++ let where = `u.is_anon = 0 AND COALESCE(u.is_banned, 0) = 0 AND u.merged_into IS NULL AND u.deleted_at IS NULL AND u.subject_id IS NOT NULL AND u.id <> ?`;
++ if (q) {
++ where += ` AND (position(lower(?) in lower(u.username)) > 0 OR position(lower(?) in lower(coalesce(u.display_name, ''))) > 0)`;
++ params.push(q, q);
++ }
++ const rows = await db.prepare(`SELECT u.id, u.subject_id, u.username, u.display_name, u.avatar_url,
++ EXISTS (SELECT 1 FROM follows f WHERE f.follower_id = ? AND f.followed_id = u.id) AS followed
++ FROM users u WHERE ${where}
++ ORDER BY followed DESC, u.username ASC LIMIT ?`).all(...params, room());
++ for (const u of rows) if (SUBJECT_RE.test(String(u.subject_id || ''))) out.push(mentionPerson(u));
++ }
++
++ if (kinds.includes('repo') && room() > 0) {
++ const token = selfToken('openvibe.git', ['git.repo.read']);
++ const gitUrl = (config && config.services && config.services.git && config.services.git.internalUrl) || '';
++ if (token && gitUrl) {
++ try {
++ const r = await fetchImpl(`${String(gitUrl).replace(/\/+$/, '')}/api/v1/repos?q=${encodeURIComponent(q)}&limit=${room()}`, {
++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(SUBJECT_RE.test(String(req.user.subject_id || '')) ? { 'X-OV-Subject': req.user.subject_id } : {}) },
++ signal: AbortSignal.timeout(2500),
++ });
++ const j = r.ok ? await r.json() : null;
++ for (const repo of (j && j.repositories) || []) {
++ if (out.length >= MAX_MENTIONS) break;
++ if (!repo || !repo.full_name) continue;
++ out.push({ kind: 'repo', id: repo.full_name, label: repo.full_name, url: repo.html_url });
++ }
++ } catch (err) { log.warn(`[Mentions] git unreachable: ${err.message}`); }
++ }
++ }
++
++ if (kinds.includes('site') && room() > 0) {
++ for (const m of (contracts.services && contracts.services.manifests) || []) {
++ if (out.length >= MAX_MENTIONS) break;
++ if (!m.domains || !m.domains.length || !m.id) continue;
++ const name = (m.site && m.site.name) || m.name || m.id;
++ const host = String(m.domains[0]);
++ if (q && !`${m.id} ${name} ${m.domains.join(' ')}`.toLowerCase().includes(q.toLowerCase())) continue;
++ out.push({ kind: 'site', id: m.id, label: name, handle: m.id, avatar: null, url: `https://${host}/` });
++ }
++ }
++
++ // `file` mentions are the message's own context (an attachment, a repository file); there is no global file
++ // index to search, so asking for that kind alone answers with an empty list.
++ res.set('Cache-Control', 'no-store').json({ mentions: out.slice(0, MAX_MENTIONS) });
++ }));
++
++ router.cleanPrefs = cleanPrefs;
++ router.parseRow = parseRow;
++ return router;
++}
++
++module.exports = { createRouter, cleanPrefs, parseRow, DEFAULTS, MAX_MENTIONS, KINDS };
ddiff --git a/server/config.js b/server/config.js
iindex 55668526..1523faaa 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -153,6 +153,9 @@ module.exports = {
internalUrl: process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200',
},
// OpenVibe.Chat: the admin panel's TTS settings (server/admin/service-proxies.js).
++ git: {
++ internalUrl: process.env.OV_GIT_INTERNAL_URL || 'http://127.0.0.1:5250',
++ },
chat: {
internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',
},
ddiff --git a/server/index.js b/server/index.js
iindex 1fa7159c..1efdd4e5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -486,6 +486,9 @@ const qrSignin = require('./auth/qr-signin').createQrSignin({
app.locals.qrSignin = qrSignin;
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
++// The shared composer (composer.prefs@1, composer.mention@1): a person's settings on every site, revision-checked,
++// and the @ menu (people they follow first, then public people; OpenVibe Git repositories; first-party sites).
++app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240 }), require('./composer/routes').createRouter({ db, requireAuth, selfToken, config }));
// Sharing (design 2026-11-06): what is shared with this person, the share picker's suggestions and
// the @name check it uses (server/sharing/routes.js).
app.use('/api/v1', require('./sharing/routes').createSharingApi({ ...sharing, requireAuth }));
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex d76e5762..9aeeba7a 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/composer.test.js b/test/composer.test.js
nnew file mode 100644
iindex 00000000..7a84f0d8
---- /dev/null
++++ b/test/composer.test.js
@@@ -0,0 +1,157 @@
++'use strict';
++// The shared composer on Network: composer.prefs@1 with its revision check (a stale tab gets 409 with the current
++// copy), composer.mention@1 from people (followed first, then public), OpenVibe Git repositories and first-party
++// sites, signed-in only, and CORS (a first-party origin yes, a foreign one refused).
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { ids } = require('openvibe-contracts');
++
++(async () => {
++ const db = require('../server/db/database').getDb();
++ const { createRouter } = require('../server/composer/routes');
++ const { gate, restrictedCors, originGuard } = require('../server/public-cors');
++
++ // A Git that answers the repository search the way Git's /api/v1/repos does.
++ const gitSeen = [];
++ const git = http.createServer((req, res) => {
++ gitSeen.push({ url: req.url, authorization: req.headers.authorization, subject: req.headers['x-ov-subject'] });
++ const q = new URL(req.url, 'http://x').searchParams.get('q') || '';
++ res.writeHead(200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ repositories: [
++ { full_name: 'OpenVibers/OpenVibe.Contracts', html_url: 'https://git.openvibe.codes/OpenVibers/OpenVibe.Contracts' },
++ { full_name: `openvibers/${q || 'demo'}`, html_url: 'https://git.openvibe.codes/openvibers/demo' },
++ ] }));
++ });
++ await new Promise((r) => git.listen(0, '127.0.0.1', r));
++ const gitUrl = `http://127.0.0.1:${git.address().port}`;
++
++ // requireAuth stand-in: X-Test-User names the session row; none -> 401, as Network answers.
++ const requireAuth = (req, res, next) => {
++ const raw = req.headers['x-test-user'];
++ if (!raw) return res.status(401).json({ error: 'Authentication required' });
++ req.user = JSON.parse(raw);
++ next();
++ };
++ const config = { baseUrl: 'https://openvibe.network', services: { git: { internalUrl: gitUrl } } };
++ const app = express();
++ const allowed = (o) => ['https://openvibe.community', 'https://openvibe.network'].includes(o);
++ const guard = originGuard(allowed, { log: { warn() {} } });
++ app.use(gate(restrictedCors(guard.origin)));
++ app.use(express.json());
++ app.use('/api/v1', createRouter({ db, requireAuth, selfToken: () => 'git-service-token', config }));
++ app.use(guard.denied);
++ 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}`;
++
++ // ── The person and the people around them ──
++ const me = { username: 'sam', display: 'Sam Rivera' };
++ const insertUser = async (username, extra = {}) => {
++ const subject = ids.newId('user');
++ const r = await db.prepare(`INSERT INTO users (username, display_name, avatar_url, password_hash, subject_id, is_anon, is_banned, profile_public)
++ VALUES (?, ?, ?, 'x', ?, ?, ?, 1) RETURNING id`).get(username, extra.display || username, extra.avatar || null, subject, extra.anon ? 1 : 0, extra.banned ? 1 : 0);
++ return { id: Number(r.id), subject_id: subject, username, display_name: extra.display || username, avatar_url: extra.avatar || null };
++ };
++ const self = await insertUser(me.username, { display: me.display });
++ const followed = await insertUser('ana', { display: 'Ana Followed' });
++ const publicPerson = await insertUser('zoe', { display: 'Zoe Public' });
++ const banned = await insertUser('banned', { display: 'Banned Person', banned: true });
++ const anon = await insertUser('anon', { display: 'Anon Person', anon: true });
++ await db.prepare('INSERT INTO follows (follower_id, followed_id) VALUES (?, ?)').run(self.id, followed.id);
++ const who = JSON.stringify({ id: self.id, subject_id: self.subject_id, username: self.username });
++ const call = async (method, p, body, headers = {}) => {
++ const h = { 'x-test-user': who, ...headers };
++ if (body !== undefined) h['content-type'] = 'application/json';
++ const r = await fetch(base + p, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body) });
++ return { status: r.status, body: await r.json().catch(() => null) };
++ };
++
++ // ── Settings: defaults, a write, a stale tab ──
++ let r = await call('GET', '/api/v1/me/composer');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.body, { rev: 0, send_key: 'enter', tools: [], density: 'comfy', voice: true, snippets: [] });
++
++ const prefs = {
++ rev: 0, send_key: 'mod-enter', density: 'compact', voice: false,
++ tools: [{ id: 'attach', hidden: false }, { id: 'gif', hidden: true }],
++ snippets: [{ trigger: '/sig', label: 'Signature', text: '— Sam' }],
++ };
++ r = await call('PUT', '/api/v1/me/composer', prefs);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.rev, 1);
++ assert.strictEqual(r.body.send_key, 'mod-enter');
++ r = await call('GET', '/api/v1/me/composer');
++ assert.deepStrictEqual(r.body, { ...prefs, rev: 1 });
++
++ // A second tab that read rev 0 is refused with the current copy, and writes nothing.
++ r = await call('PUT', '/api/v1/me/composer', { ...prefs, rev: 0, density: 'comfy' });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.body.code, 'composer.rev_conflict');
++ assert.strictEqual(r.body.current.rev, 1);
++ assert.strictEqual(r.body.current.density, 'compact');
++ assert.deepStrictEqual((await call('GET', '/api/v1/me/composer')).body, { ...prefs, rev: 1 });
++
++ // Invalid documents are refused: a trigger that is not a slash word, 51 snippets, an unknown field.
++ r = await call('PUT', '/api/v1/me/composer', { ...prefs, rev: 1, snippets: [{ trigger: 'sig', label: 'Signature', text: 'x' }] });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.body.code, 'composer.invalid_prefs');
++ r = await call('PUT', '/api/v1/me/composer', { ...prefs, rev: 1, snippets: Array.from({ length: 51 }, (_, i) => ({ trigger: `/s${i}`, label: `s${i}`, text: 'x' })) });
++ assert.strictEqual(r.status, 422);
++ r = await call('PUT', '/api/v1/me/composer', { ...prefs, rev: 1, extra: true });
++ assert.strictEqual(r.status, 422);
++
++ // ── Mentions: people the person follows first, then public people ──
++ r = await call('GET', '/api/v1/mentions?q=o');
++ assert.strictEqual(r.status, 200);
++ const people = r.body.mentions.filter((m) => m.kind === 'person');
++ assert.strictEqual(people[0].id, followed.subject_id, 'a followed person comes first');
++ assert.strictEqual(people[0].handle, 'ana');
++ assert.strictEqual(people[0].label, 'Ana Followed');
++ assert.match(people[0].avatar, /^https:\/\/openvibe\.network\/avatar\/ana\?s=64$/);
++ assert.ok(!people.some((p) => p.id === banned.subject_id), 'a banned person is not mentioned');
++ assert.ok(!people.some((p) => p.id === anon.subject_id), 'an anonymous account is not mentioned');
++ assert.ok(!people.some((p) => p.id === self.subject_id), 'a person does not mention themselves');
++
++ // Repositories come from Git with the person's subject and Network's service token.
++ r = await call('GET', '/api/v1/mentions?q=contract&kinds=repo');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.body.mentions.length >= 1);
++ assert.deepStrictEqual([...new Set(r.body.mentions.map((m) => m.kind))], ['repo']);
++ assert.strictEqual(r.body.mentions[0].id, 'OpenVibers/OpenVibe.Contracts');
++ assert.match(r.body.mentions[0].url, /^https:\/\/git\.openvibe\.codes\//);
++ assert.strictEqual(gitSeen[0].authorization, 'Bearer git-service-token');
++ assert.strictEqual(gitSeen[0].subject, self.subject_id, 'Git is asked as the person');
++
++ // First-party sites, and the 8-entry ceiling.
++ r = await call('GET', '/api/v1/mentions?q=chat&kinds=site');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.body.mentions.some((m) => m.kind === 'site' && m.id === 'chat'));
++ for (let i = 0; i < 10; i++) await insertUser(`extra${i}`);
++ r = await call('GET', '/api/v1/mentions?q=extra');
++ assert.ok(r.body.mentions.length <= 8, 'at most 8 mentions');
++
++ // A kind outside the list is refused; file-only is an empty list (files are the message's own context).
++ r = await call('GET', '/api/v1/mentions?kinds=channel');
++ assert.strictEqual(r.status, 422);
++ r = await call('GET', '/api/v1/mentions?kinds=file');
++ assert.deepStrictEqual(r.body.mentions, []);
++
++ // ── Signed-in only ──
++ let res = await fetch(`${base}/api/v1/mentions`, { headers: { 'x-test-user': '' } });
++ assert.strictEqual(res.status, 401);
++ res = await fetch(`${base}/api/v1/me/composer`);
++ assert.strictEqual(res.status, 401);
++
++ // ── CORS: a first-party origin, and a refused foreign one ──
++ res = await fetch(`${base}/api/v1/mentions`, { method: 'OPTIONS', headers: { Origin: 'https://openvibe.community', 'Access-Control-Request-Method': 'GET', 'Access-Control-Request-Headers': 'authorization' } });
++ assert.strictEqual(res.status, 204);
++ assert.strictEqual(res.headers.get('access-control-allow-origin'), 'https://openvibe.community');
++ res = await fetch(`${base}/api/v1/mentions`, { method: 'OPTIONS', headers: { Origin: 'https://evil.example', 'Access-Control-Request-Method': 'GET' } });
++ assert.strictEqual(res.status, 403);
++ assert.strictEqual((await res.json()).code, 'cors.origin_denied');
++
++ await new Promise((r2) => server.close(r2));
++ await new Promise((r2) => git.close(r2));
++ console.log('composer: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 96183cf4..b0deda67 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -115,6 +115,7 @@ const REVIEWED = {
'server/connect/egress.js http.request': [1, 'Connect gateway (E7): only the app definition\'s fixed API host, through openvibe-shared/egress (IP literals refused before the socket, public DNS answers only, redirects followed by hand with every hop re-checked); never a URL from the input (Opus review 11-06)'],
'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
'server/home/popular.js fetch': [2, 'Report public popularity and co-usage: the operator-configured first-party Report URL (OV_REPORT_INTERNAL_URL), a fixed loopback origin, never a request value'],
++ 'server/composer/routes.js fetch': [2, 'mention search: the operator-configured OpenVibe.Git internal URL (config.services.git) with a fixed /api/v1/repos path and the query percent-encoded; no caller-chosen host, 2.5 s timeout, and no token or body is sent with it'],
'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],
'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 681c64ac..22f43b9f 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.