openvibers/openvibe.network Commits
The composer's settings and @ menu (composer.prefs@1, composer.mention@1) (#806)
Changes
nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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 };@@@ -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',},@@@ -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 }));@@@ -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.nnew file mode 100644@@@ -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); });@@@ -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'],@@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -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 };
@@ -153,6 +153,9 @@ module.exports = {
@@ -153,6 +153,9 @@ module.exports = {
internalUrl: process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200',
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).
// 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: {
chat: {
internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',
internalUrl: process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400',
},
},
@@ -486,6 +486,9 @@ const qrSignin = require('./auth/qr-signin').createQrSignin({
@@ -486,6 +486,9 @@ const qrSignin = require('./auth/qr-signin').createQrSignin({
app.locals.qrSignin = qrSignin;
app.locals.qrSignin = qrSignin;
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
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
// 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).
// the @name check it uses (server/sharing/routes.js).
app.use('/api/v1', require('./sharing/routes').createSharingApi({ ...sharing, requireAuth }));
app.use('/api/v1', require('./sharing/routes').createSharingApi({ ...sharing, requireAuth }));
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -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); });
@@ -115,6 +115,7 @@ const REVIEWED = {
@@ -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/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/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/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/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/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'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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.
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.