openvibers/openvibe.network Commits

Frame: the composer's / commands and @ mentions (R3) (#864)

OP OpenVibers committed 1 days ago · 2e8903a28d · 9 files, +765 −6

GET /api/frame/commands?site=<host>&q= returns the rows a composer's slash menu offers: the site's own from
its Frame manifest first, then the network's /ask, /do, /remind and /timer, then a signed-in person's saved
composer snippets. A row is { name, hint, insert, kind, needs_sign_in }; signed out the answer is public for
60 s, signed in it is private and never kept.

GET /api/frame/mentions?q=&site= returns at most eight rows: public people first (canonical subject id, handle,
display name, and an avatar only when it is on openvibe.media), then the person's own agents from the agent
directory, then open OpenVibe sites and Live's public channels. Signed out it is public people only; the read
is rate-limited per actor in memory and never returns an email or a private account field.

Docs and llms.txt name both; tests cover signed in and out, caching, ranking, the per-actor limit and the
private-field rule. The SSRF review lists the one new outbound call (Live's fixed public list).

Changes

ddiff --git a/docs/frame.md b/docs/frame.md
iindex 0c7b0e22..095b114e 100644
---- a/docs/frame.md
++++ b/docs/frame.md
@@@ -1,8 +1,8 @@
--# The composer's Frame: models and answers
++# The composer's Frame: models, answers, commands and mentions
Every OpenVibe site's composer reaches models, search and the person's own work through OpenVibe.Network's Frame. A
--site never holds another site's keys: it sends the person's own Network token, and Network forwards it. Both routes
--below answer `Access-Control-Allow-Origin` only for OpenVibe's own origins, like `/api/frame/query`.
++site never holds another site's keys: it sends the person's own Network token, and Network forwards it. Every route
++below answers `Access-Control-Allow-Origin` only for OpenVibe's own origins, like `/api/frame/query`.
## `GET /api/frame/choices?task=chat|agent|code|image|research`
@@@ -77,3 +77,54 @@ Events are AG-UI: `RUN_STARTED`, `STEP_STARTED`/`STEP_FINISHED` (`Search`, `Read
and `RUN_FINISHED` with the same answer and citations. A failure is `RUN_ERROR` with `{ code, message }`. The run is
kept in memory for five minutes after it ends, so a reconnect can resume; `GET /api/v1/runs/:id` is its snapshot.
Asks are rate-limited per person (12 a minute).
++
++## `GET /api/frame/commands?site=<host>&q=`
++
++The `/` menu. Every site offers the network's four commands, a site adds its own from its Frame manifest
++(`site.commands`), and a signed-in person's saved composer snippets come last as theirs. The site's own come first,
++because they are the ones that fit the box; a name appears once, so a site may replace a network command with its own.
++`q` filters by the command's name (prefix) or its hint. Each row is `{ name, hint, insert, kind, needs_sign_in }`;
++`name` is the word after the slash, `insert` is what a pick puts in the box, `kind` is `network`, `site` or `mine`,
++and `needs_sign_in` says the command acts for a person (the composer may offer it and then sign in).
++
++```json
++{
++ "site": { "id": "live", "name": "Live", "host": "openvibe.live" },
++ "commands": [
++ { "name": "clip", "hint": "Make a clip of the stream", "insert": "/clip ", "kind": "site", "needs_sign_in": true },
++ { "name": "ask", "hint": "Ask Vibe: an answer with its sources", "insert": "/ask ", "kind": "network", "needs_sign_in": true },
++ { "name": "do", "hint": "Hand it to your agent: Actor plans and runs it", "insert": "/do ", "kind": "network", "needs_sign_in": true },
++ { "name": "remind", "hint": "Have Today remind you", "insert": "/remind ", "kind": "network", "needs_sign_in": true },
++ { "name": "timer", "hint": "Start a focus timer in the Dock", "insert": "/timer ", "kind": "network", "needs_sign_in": true },
++ { "name": "sig", "hint": "Signature", "insert": "— Sam", "kind": "mine", "needs_sign_in": false }
++ ]
++}
++```
++
++`site` may be a hostname, a URL or a site id (`live`); an unknown site answers `site: null` with the network four. A
++signed-out answer is `Cache-Control: public, max-age=60` and held a minute; a signed-in one (the snippets need the
++person) is `private, no-store` and asked fresh.
++
++## `GET /api/frame/mentions?q=&site=`
++
++The `@` menu: public people, the person's own agents from Network's agent directory, and OpenVibe places (open sites
++and Live's public channels). At most eight rows, people first, then the person's agents, then places. Signed out it is
++public people only. A person row carries the canonical subject id, the public handle, the display name and, only when
++the picture is on OpenVibe.Media, the avatar; never an email, a numeric account id, a role or any other account field.
++`site` is the host the box is on: that site is not suggested back to itself.
++
++```json
++{
++ "mentions": [
++ { "kind": "person", "id": "usr_…", "label": "Ana Followed", "handle": "ana",
++ "avatar": "https://openvibe.media/p/ana/screenshot", "url": "https://openvibe.network/@ana" },
++ { "kind": "agent", "id": "agt_…", "label": "Actor", "sub": "actor · online" },
++ { "kind": "site", "id": "live", "label": "Live", "handle": "live", "url": "https://openvibe.live/" },
++ { "kind": "channel", "id": "streamer", "label": "Streamer One", "handle": "streamer", "url": "https://openvibe.live/@streamer" }
++ ]
++}
++```
++
++A person's answer is `private, no-store`; a signed-out one is `public, max-age=60` and held a minute. The read is
++rate-limited per actor in memory (120 a minute; a signed-in subject, or a hash of the address when signed out) and
++answers `429` with `Retry-After` and `code: "frame.mentions_rate_limited"` past it.
ddiff --git a/server/frame/commands.js b/server/frame/commands.js
nnew file mode 100644
iindex 00000000..dc90ec59
---- /dev/null
++++ b/server/frame/commands.js
@@@ -0,0 +1,107 @@
++'use strict';
++/**
++ * The composer's slash commands (composer-post-ask-do.md, R3): GET /api/frame/commands?site=<host>&q=.
++ *
++ * Every site offers the network's four: /ask (Vibe answers with sources), /do (Actor does it), /remind
++ * (Today remembers it) and /timer (the Dock's focus timer). A site adds its own from its manifest's
++ * `site.commands` block when it has one, and a signed-in person's saved composer snippets are their
++ * own commands. A row is `{ name, hint, insert, kind, needs_sign_in }`:
++ *
++ * name the word after the slash, lower-case, no slash
++ * hint one line the popover shows beside it
++ * insert the text a pick puts in the box (the word, a space, ready for its arguments)
++ * kind network | site | mine
++ * needs_sign_in the command acts for a person, so a signed-out box may offer it and then sign in
++ *
++ * Site commands come first (the box shows the ones that fit its site first), then the network's, then
++ * the person's own. A name appears once: a site may replace a network command of the same name.
++ */
++const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
++const MAX_COMMANDS = 50;
++const MAX_SITE_COMMANDS = 12;
++
++// The four every box offers. Their hints are the composer's own words, not product names.
++const DEFAULTS = Object.freeze([
++ { name: 'ask', hint: 'Ask Vibe: an answer with its sources', insert: '/ask ', kind: 'network', needs_sign_in: true },
++ { name: 'do', hint: 'Hand it to your agent: Actor plans and runs it', insert: '/do ', kind: 'network', needs_sign_in: true },
++ { name: 'remind', hint: 'Have Today remind you', insert: '/remind ', kind: 'network', needs_sign_in: true },
++ { name: 'timer', hint: 'Start a focus timer in the Dock', insert: '/timer ', kind: 'network', needs_sign_in: true },
++]);
++
++const clean = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
++const nameOf = (v) => String(v == null ? '' : v).trim().replace(/^\/+/, '').toLowerCase();
++
++/** One row from a manifest command or a saved snippet, or null when it is not a command we can offer. */
++function rowOf(raw, kind) {
++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
++ const name = nameOf(raw.name != null ? raw.name : raw.trigger);
++ if (!NAME_RE.test(name)) return null;
++ const hint = clean(raw.hint != null ? raw.hint : (raw.summary != null ? raw.summary : raw.label), 120);
++ const insert = String(raw.insert != null ? raw.insert : raw.text != null ? raw.text : `/${name} `)
++ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
++ .slice(0, 4000);
++ return { name, hint, insert, kind, needs_sign_in: raw.needs_sign_in === true || raw.needsSignIn === true };
++}
++
++/** The site's own commands from its Frame manifest entry (`site.commands`), at most twelve. */
++function siteRows(site) {
++ const out = [];
++ for (const raw of Array.isArray(site && site.commands) ? site.commands.slice(0, MAX_SITE_COMMANDS) : []) {
++ const row = rowOf(raw, 'site');
++ if (row) out.push(row);
++ }
++ return out;
++}
++
++/** A person's saved composer snippets (composer.prefs@1 `snippets`) as their own commands. */
++function savedRows(prefs) {
++ let snippets = null;
++ try { snippets = prefs ? JSON.parse(prefs) : null; } catch { snippets = null; }
++ const list = snippets && Array.isArray(snippets.snippets) ? snippets.snippets : [];
++ const out = [];
++ for (const s of list.slice(0, MAX_COMMANDS)) {
++ const row = rowOf(s, 'mine');
++ if (row) out.push(row);
++ }
++ return out;
++}
++
++/** The site an asking composer means: a hostname, a URL or a site id (the icon name). */
++function siteOf(value, sites, siteForHost) {
++ const raw = String(value == null ? '' : value).trim().toLowerCase();
++ if (!raw) return null;
++ let host = raw;
++ try { if (raw.includes('://')) host = new URL(raw).hostname; } catch { /* a bare host or id */ }
++ host = host.replace(/:\d+$/, '').replace(/^www\./, '').replace(/\/.*$/, '');
++ const listed = (sites || []).find((s) => s && (s.id === host || s.id === raw || s.host === host));
++ if (listed) return listed;
++ return typeof siteForHost === 'function' ? siteForHost(host) : null;
++}
++
++/**
++ * The body of GET /api/frame/commands.
++ * `signedIn` adds the person's saved snippets (`prefs` is their stored composer_prefs JSON).
++ */
++function read({ site = '', q = '', signedIn = false, prefs = null, sites = [], siteForHost = null } = {}) {
++ const found = siteOf(site, sites, siteForHost);
++ const query = String(q == null ? '' : q).trim().toLowerCase().slice(0, 50);
++ const rows = [];
++ const seen = new Set();
++ const add = (row) => {
++ if (!row || seen.has(row.name) || rows.length >= MAX_COMMANDS) return;
++ seen.add(row.name);
++ rows.push(row);
++ };
++ for (const row of siteRows(found)) add(row);
++ for (const row of DEFAULTS) add(row);
++ if (signedIn) for (const row of savedRows(prefs)) add(row);
++ const commands = query
++ ? rows.filter((r) => r.name.startsWith(query) || r.hint.toLowerCase().includes(query))
++ : rows;
++ return {
++ site: found ? { id: found.id, name: found.name, host: found.host } : null,
++ commands,
++ };
++}
++
++module.exports = { read, rowOf, siteOf, savedRows, siteRows, DEFAULTS, NAME_RE, MAX_COMMANDS, MAX_SITE_COMMANDS };
ddiff --git a/server/frame/mentions.js b/server/frame/mentions.js
nnew file mode 100644
iindex 00000000..b62604c3
---- /dev/null
++++ b/server/frame/mentions.js
@@@ -0,0 +1,266 @@
++'use strict';
++/**
++ * The composer's @ menu (composer-post-ask-do.md, R3): GET /api/frame/mentions?q=&site=.
++ *
++ * One list for every site, the network's own:
++ *
++ * person a Network account: its public username, display name and, only when it is an OpenVibe.Media
++ * picture, its avatar. Never an email, a numeric id, a role or any other account field.
++ * agent one of the person's agents from Network's agent directory (signed in only).
++ * site an open OpenVibe place, from the Frame's site list (signed in only).
++ * channel a public live channel on OpenVibe.Live (signed in only; Live's public list, never a private row).
++ *
++ * At most eight rows, people first, then the person's agents, then places. Signed out answers public
++ * people only. Signed in, people the person follows and an exact username match come first.
++ *
++ * The read is rate-limited per actor in this process (a subject when signed in, a hash of the address
++ * when not): `/api/frame` already has its per-address limit; this one keeps one person's type-ahead
++ * from spending everyone else's.
++ */
++const crypto = require('crypto');
++
++const MAX = 8;
++const Q_MAX = 50;
++const MINUTE = 60_000;
++const CHANNELS_MS = 30_000;
++const MEDIA_HOST = /(^|\.)openvibe\.media$/i;
++const USERNAME_RE = /^[A-Za-z0-9_]{1,40}$/;
++
++const text = (v, max) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
++
++/** An avatar is a public OpenVibe.Media picture, or none: never a Network-served, private or third-party URL. */
++function avatarOf(value) {
++ const raw = String(value == null ? '' : value);
++ if (!raw || raw.length > 500) return null;
++ try {
++ const u = new URL(raw);
++ if (u.protocol !== 'https:' || u.username || u.password) return null;
++ return MEDIA_HOST.test(u.hostname) ? raw : null;
++ } catch { return null; }
++}
++
++/** The row a person gets: their canonical subject id, public handle, display name and public picture. */
++function personRow(u) {
++ if (!u || !USERNAME_RE.test(String(u.username || '')) || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(u.subject_id || ''))) return null;
++ const avatar = avatarOf(u.avatar_url);
++ return {
++ kind: 'person',
++ id: String(u.subject_id),
++ label: text(u.display_name, 120) || u.username,
++ handle: u.username,
++ ...(avatar ? { avatar } : {}),
++ url: `https://openvibe.network/@${encodeURIComponent(u.username)}`,
++ };
++}
++
++function agentRow(a) {
++ if (!a || !/^agt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(a.id || ''))) return null;
++ const kind = text(a.kind, 20);
++ const status = text(a.status, 20);
++ return {
++ kind: 'agent',
++ id: String(a.id),
++ label: text(a.name, 120) || 'An agent',
++ ...(kind || status ? { sub: [kind, status].filter(Boolean).join(' · ') } : {}),
++ };
++}
++
++function siteRow(s, { fullName = (x) => x.name } = {}) {
++ if (!s || !s.id || s.status !== 'open' || !s.host) return null;
++ return {
++ kind: 'site',
++ id: String(s.id),
++ label: text(s.of ? s.name : fullName(s), 120),
++ handle: String(s.id),
++ url: `https://${s.host}/`,
++ };
++}
++
++function channelRow(st) {
++ if (!st || !USERNAME_RE.test(String(st.username || ''))) return null;
++ const avatar = avatarOf(st.avatar_url);
++ return {
++ kind: 'channel',
++ id: String(st.username),
++ label: text(st.display_name || st.username, 120) || st.username,
++ handle: String(st.username),
++ ...(avatar ? { avatar } : {}),
++ url: `https://openvibe.live/@${encodeURIComponent(st.username)}`,
++ };
++}
++
++/** Public Live streams only: a row the site marks unlisted/private never reaches a mention list. */
++function publicLive(st) {
++ if (!st || typeof st !== 'object') return false;
++ if (st.visibility && String(st.visibility).toLowerCase() !== 'public') return false;
++ if (st.is_public === false || st.unlisted === true || st.is_unlisted === true || st.listed === false) return false;
++ return true;
++}
++
++function createFrameMentions({
++ db,
++ sites = [],
++ siteForHost = null,
++ fullName = (s) => s.name,
++ liveUrl = '',
++ fetchImpl = globalThis.fetch,
++ now = Date.now,
++ log = console,
++ perMinute = 120,
++} = {}) {
++ const liveBase = String(liveUrl || '').replace(/\/+$/, '');
++ const held = new Map(); // signed-out answer, by q
++ const hits = new Map(); // actor -> { n, reset }
++ let channels = null, channelsAt = 0, channelsInflight = null;
++
++ async function getJson(url, timeoutMs) {
++ if (typeof fetchImpl !== 'function') return null;
++ try {
++ const r = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
++ return r && r.ok ? await r.json() : null;
++ } catch (err) {
++ if (log && log.warn) log.warn(`[FrameMentions] ${url}: ${String((err && err.message) || err).slice(0, 160)}`);
++ return null;
++ }
++ }
++
++ /** The public live channels, held 30 s: Live's list is public, its private rows are not our business. */
++ async function liveChannels() {
++ if (channels && now() - channelsAt < CHANNELS_MS) return channels;
++ if (!liveBase) return [];
++ channelsInflight = channelsInflight || getJson(`${liveBase}/api/streams`, 2500).then((d) => {
++ const streams = d && Array.isArray(d.streams) ? d.streams.filter(publicLive) : [];
++ return streams.map(channelRow).filter(Boolean).slice(0, 20);
++ }).finally(() => { channelsInflight = null; });
++ const rows = await channelsInflight;
++ if (rows.length) { channels = rows; channelsAt = now(); }
++ return rows;
++ }
++
++ /** The current site, from a hostname, a URL or a site id; null when the composer names none. */
++ function siteOf(value) {
++ const raw = String(value == null ? '' : value).trim().toLowerCase();
++ if (!raw) return null;
++ let host = raw;
++ try { if (raw.includes('://')) host = new URL(raw).hostname; } catch { /* a bare host or id */ }
++ host = host.replace(/:\d+$/, '').replace(/^www\./, '').replace(/\/.*$/, '');
++ const listed = (sites || []).find((s) => s && (s.id === host || s.id === raw || s.host === host));
++ if (listed) return listed;
++ return typeof siteForHost === 'function' ? siteForHost(host) : null;
++ }
++
++ /** At most eight; people first, then this person's agents, then places. */
++ async function get({ q = '', site = '', user = null } = {}) {
++ const query = text(q, Q_MAX).toLowerCase();
++ const signedIn = Boolean(user && (user.id != null || user.subject_id));
++ const current = siteOf(site);
++ if (!signedIn) {
++ const hit = held.get(query);
++ if (hit && now() - hit.at < MINUTE) return { body: hit.body, cached: true };
++ const people = await peopleRows(query, null);
++ const body = { mentions: people.slice(0, MAX) };
++ if (held.size >= 500) held.clear();
++ held.set(query, { at: now(), body });
++ return { body, cached: false };
++ }
++
++ const out = [];
++ const room = () => MAX - out.length;
++ for (const row of await peopleRows(query, user)) { if (out.length >= MAX) break; out.push(row); }
++ if (room() > 0) for (const row of await agentRows(query, user)) { if (out.length >= MAX) break; out.push(row); }
++ if (room() > 0) {
++ for (const s of sites) {
++ if (out.length >= MAX) break;
++ if (!s || s.status !== 'open' || (current && s.id === current.id)) continue;
++ if (query && !`${s.id} ${s.name} ${s.host} ${s.tagline || ''}`.toLowerCase().includes(query)) continue;
++ const row = siteRow(s, { fullName });
++ if (row) out.push(row);
++ }
++ }
++ if (room() > 0) {
++ for (const row of await liveChannels()) {
++ if (out.length >= MAX) break;
++ if (query && !`${row.id} ${row.label}`.toLowerCase().includes(query)) continue;
++ out.push(row);
++ }
++ }
++ return { body: { mentions: out.slice(0, MAX) }, cached: false };
++ }
++
++ async function peopleRows(query, user) {
++ if (!db) return [];
++ const signedIn = Boolean(user && (user.id != null || user.subject_id));
++ const params = [];
++ let sql = 'SELECT u.id, u.subject_id, u.username, u.display_name, u.avatar_url';
++ if (signedIn) {
++ sql += ', EXISTS (SELECT 1 FROM follows f WHERE f.follower_id = ? AND f.followed_id = u.id) AS followed';
++ params.push(user.id);
++ }
++ sql += ` FROM users u 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`;
++ if (signedIn) { sql += ' AND u.id <> ?'; params.push(user.id); }
++ else sql += ' AND u.profile_public = 1';
++ if (query) {
++ sql += " AND (position(lower(?) in lower(u.username)) > 0 OR position(lower(?) in lower(coalesce(u.display_name, ''))) > 0)";
++ params.push(query, query);
++ }
++ const order = [];
++ if (query) { order.push('(lower(u.username) = lower(?)) DESC'); params.push(query); }
++ if (signedIn) order.push('followed DESC');
++ order.push('u.username ASC');
++ sql += ` ORDER BY ${order.join(', ')} LIMIT ?`;
++ params.push(MAX);
++ try {
++ const rows = await db.prepare(sql).all(...params);
++ return rows.map(personRow).filter(Boolean).slice(0, MAX);
++ } catch (err) {
++ if (log && log.warn) log.warn(`[FrameMentions] people: ${String((err && err.message) || err).slice(0, 160)}`);
++ return [];
++ }
++ }
++
++ async function agentRows(query, user) {
++ if (!db || !user) return [];
++ let subject = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || '')) ? String(user.subject_id) : '';
++ if (!subject && user.id != null) {
++ try { const row = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(user.id); subject = (row && row.subject_id) || ''; } catch { subject = ''; }
++ }
++ if (!subject) return [];
++ const params = [subject];
++ let sql = 'SELECT id, kind, name, service, machine_id, status FROM agent_directory WHERE owner_subject = ?';
++ if (query) { sql += ' AND position(lower(?) in lower(name)) > 0'; params.push(query); }
++ sql += " ORDER BY CASE status WHEN 'online' THEN 0 WHEN 'busy' THEN 1 ELSE 2 END, updated_at DESC, id ASC LIMIT ?";
++ params.push(MAX);
++ try {
++ const rows = await db.prepare(sql).all(...params);
++ return rows.map(agentRow).filter(Boolean).slice(0, MAX);
++ } catch (err) {
++ if (log && log.warn) log.warn(`[FrameMentions] agents: ${String((err && err.message) || err).slice(0, 160)}`);
++ return [];
++ }
++ }
++
++ /** The per-actor window: 120 a minute for one person, one hashed address when signed out. */
++ function allow(actor) {
++ const at = now();
++ const key = String(actor || '');
++ if (!key) return { ok: true };
++ let w = hits.get(key);
++ if (!w || at >= w.reset) { w = { n: 0, reset: at + MINUTE }; hits.set(key, w); }
++ if (w.n >= perMinute) return { ok: false, retryAfter: Math.max(1, Math.ceil((w.reset - at) / 1000)) };
++ w.n++;
++ if (hits.size > 20_000) for (const [k, v] of hits) if (at >= v.reset) hits.delete(k);
++ return { ok: true };
++ }
++
++ /** The address a signed-out request is counted by: a short hash in memory, never the address itself. */
++ function actorKey(req, user) {
++ if (user && (user.subject_id || user.id != null)) return `user:${user.subject_id || user.id}`;
++ const ip = String((req && req.ip) || '');
++ return `ip:${crypto.createHash('sha256').update(ip).digest('hex').slice(0, 20)}`;
++ }
++
++ return { get, allow, actorKey, siteOf, _state: () => ({ held: held.size, hits: hits.size }) };
++}
++
++module.exports = { createFrameMentions, avatarOf, personRow, agentRow, siteRow, channelRow, publicLive, MAX, MINUTE };
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 54ab1198..8f65a70b 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -4,6 +4,8 @@
//
// GET /api/frame?host=<hostname> public, CORS *, max-age=600, ETag
// GET /api/frame/pulse public, CORS *, max-age=30: { live_now, watching, sites, views_7d, live, updated }
++// GET /api/frame/commands?site=&q= the composer's / menu: site commands, network defaults, a person's own
++// GET /api/frame/mentions?q=&site= the composer's @ menu: public people, the person's agents, places (max 8)
// GET /api/frame/feed, /api/frame/wall the menu's Feed and wall: Community's /api/v1/feed and /api/v1/pulse, relayed
// GET|POST|PUT /api/frame/wallet/… the wallet sheet's fixed Billing/Inventory paths, relayed (bearer, or the ov_token cookie; no cache)
// POST /api/frame/menu-click public, CORS *, text/plain, max 512 bytes, 60/min per IP: one menu click counted
@@@ -32,6 +34,8 @@ const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MER
const { createMenuCards } = require('./menu-cards');
const { createFrameChoices } = require('./choices');
const { createFrameAsk } = require('./ask');
++const { read: readCommands } = require('./commands');
++const { createFrameMentions } = require('./mentions');
const seen = require('./seen');
const toolsCatalog = require('../domains/catalog');
@@@ -165,7 +169,7 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {
};
}
--async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = null, requireAuth = null, today = null, now = Date.now } = {}) {
++async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = null, requireAuth = null, person = null, today = null, now = Date.now, sites = SITES, places = LISTED, mentionLimit = 120 } = {}) {
const services = (config && config.services) || {};
aiUrl = aiUrl || (services.ai && services.ai.internalUrl) || process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700';
const choices = createFrameChoices({
@@@ -239,6 +243,23 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
try { const r = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) }); return r.ok ? await r.json() : null; } catch { return null; }
}
++ // The composer's Reach (composer-post-ask-do.md, R3): the site's slash commands and the @ menu. Both
++ // read a person's session when one is sent (Bearer or the Network cookie) and answer as a signed-out
++ // visitor otherwise; neither keeps a person's own answer.
++ const sessionAuth = require('../auth/session');
++ async function defaultPerson(req) {
++ const token = sessionAuth.sessionToken(req);
++ if (!token) return null;
++ const publicKey = req.app && req.app.locals && req.app.locals.publicKey;
++ if (!publicKey || !config || !config.jwt || !config.jwt.issuer) return null;
++ try { const out = await sessionAuth.verifySession(token, { db, publicKey, config }); return out && !out.error ? out.user : null; } catch { return null; }
++ }
++ const personOf = typeof person === 'function' ? person : defaultPerson;
++ const mentions = createFrameMentions({
++ db, sites: places, siteForHost, fullName, now, perMinute: mentionLimit,
++ liveUrl: svcUrl('live', 'http://127.0.0.1:3000'),
++ });
++
async function refreshRank() {
const scores = Object.assign({}, rank.scores);
// Live runs its own navbar and its own analytics: ask it for totals (live.analytics.read, totals only).
@@@ -1018,6 +1039,52 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.send(JSON.stringify(got.body));
});
++ // GET /api/frame/commands?site=<host>&q= — the composer's slash commands (composer-post-ask-do.md, R3):
++ // every site's /ask, /do, /remind and /timer, the site's own from its manifest, and a signed-in person's
++ // saved snippets. Site commands come first, a name appears once, and q filters by name or hint.
++ // Signed out the answer is public and held 60 s; signed in it is the person's own and never kept.
++ const commandHeld = new Map();
++ router.get('/commands', async (req, res) => {
++ res.set({ 'Content-Type': 'application/json; charset=utf-8', Vary: 'Authorization, Origin' });
++ const site = String(req.query.site || '').slice(0, 255);
++ const q = String(req.query.q || '').slice(0, 50);
++ const user = await personOf(req).catch(() => null);
++ let prefs = null;
++ if (user && user.id != null) {
++ try { const row = await db.prepare('SELECT composer_prefs FROM user_preferences WHERE user_id = ?').get(user.id); prefs = row ? row.composer_prefs : null; } catch { prefs = null; }
++ }
++ if (user) {
++ res.set('Cache-Control', 'private, no-store');
++ } else {
++ const key = `${site.toLowerCase()}|${q.toLowerCase()}`;
++ const kept = commandHeld.get(key);
++ res.set('Cache-Control', 'public, max-age=60');
++ if (kept && now() - kept.at < 60_000) return res.send(kept.body);
++ const body = JSON.stringify(readCommands({ site, q, prefs: null, sites, siteForHost }));
++ if (commandHeld.size >= 500) commandHeld.clear();
++ commandHeld.set(key, { at: now(), body });
++ return res.send(body);
++ }
++ res.send(JSON.stringify(readCommands({ site, q, signedIn: true, prefs, sites, siteForHost })));
++ });
++
++ // GET /api/frame/mentions?q=&site= — the composer's @ menu (composer-post-ask-do.md, R3): public people
++ // first, then the person's own agents from the agent directory, then OpenVibe's places (open sites and
++ // Live's public channels). At most eight; signed out it is public people only. Rate-limited per actor in
++ // memory (a signed-in subject, or a hash of the address), never an email or a private account field.
++ router.get('/mentions', async (req, res) => {
++ res.set({ 'Content-Type': 'application/json; charset=utf-8', Vary: 'Authorization, Origin' });
++ const user = await personOf(req).catch(() => null);
++ const allowed = mentions.allow(mentions.actorKey(req, user));
++ if (!allowed.ok) {
++ res.set({ 'Cache-Control': 'private, no-store', 'Retry-After': String(allowed.retryAfter) });
++ return res.status(429).json({ error: 'Too many mention searches: try again in a minute', code: 'frame.mentions_rate_limited' });
++ }
++ const got = await mentions.get({ q: String(req.query.q || '').slice(0, 50), site: String(req.query.site || '').slice(0, 255), user });
++ res.set('Cache-Control', user ? 'private, no-store' : 'public, max-age=60');
++ res.send(JSON.stringify(got.body));
++ });
++
// POST /api/frame/ask — one question, answered on the chosen model with the Search Fabric's results for the
// chosen scopes, streamed on Network's runs. Signed in only; the person's own token pays. The run's events are at
// /api/v1/runs/:id/events (mounted in server/index.js), replayable from Last-Event-ID.
ddiff --git a/server/frame/sites.js b/server/frame/sites.js
iindex 352e4058..1aff52a5 100644
---- a/server/frame/sites.js
++++ b/server/frame/sites.js
@@@ -22,6 +22,9 @@ const RAW_SITES = contracts.services.manifests.filter((m) => m.site && !HIDDEN_S
// A site's own footer-strip items (label, icon, link, and a number or a line), at most three. They are
// validated again at the read (server/frame/service.js stripOf): text only, an https link on an OpenVibe domain.
...(Array.isArray(m.site.strip) ? { strip: m.site.strip } : {}),
++ // The site's own composer commands (composer-post-ask-do.md, R3), validated again at the read
++ // (server/frame/commands.js siteRows): a slash word, a one-line hint and what a pick inserts.
++ ...(Array.isArray(m.site.commands) ? { commands: m.site.commands } : {}),
...(m.site.host ? { host: m.site.host } : {}),
})).concat(contracts.services.get('computer') ? [] : [COMPUTER_FALLBACK]);
function manifestHost(service) {
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 7d3154b6..6b4f0647 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -570,7 +570,7 @@ const ACCOUNT_ID_LINK = {
const ACCOUNT_ID_TEXT = 'GET /api/auth/me with a person\'s token answers { user: { subject_id: "usr_…" } }. That usr_… value is the person\'s OpenVibe account ID: what apps and robots ask for when they ask for someone\'s OpenVibe account ID. A person sees their own on their account page (https://openvibe.network/my); nobody else can read it.';
// A path with a variable is a route to call, not a page to fetch: it is written as code in llms.txt, never as a
// Markdown link, so an agent (or a checker doing a GET) does not ask for the literal @handle address.
--const API_ROUTE_TEXT = 'API routes with a variable in the path are code, not pages to fetch: `GET /api/v1/people/@handle/page` is the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places. The composer\'s model sheet is `GET /api/frame/choices?task=chat|agent|code|image|research`; a question is `POST /api/frame/ask`, and its AG-UI answer streams at `GET /api/v1/runs/<id>/events` (Last-Event-ID resumes).';
++const API_ROUTE_TEXT = 'API routes with a variable in the path are code, not pages to fetch: `GET /api/v1/people/@handle/page` is the signed-out profile at https://openvibe.network/@handle, including its public OpenVibe places. The composer\'s model sheet is `GET /api/frame/choices?task=chat|agent|code|image|research`; a question is `POST /api/frame/ask`, and its AG-UI answer streams at `GET /api/v1/runs/<id>/events` (Last-Event-ID resumes). The composer\'s `/` menu is `GET /api/frame/commands?site=<host>&q=` (the site\'s own, the network\'s /ask, /do, /remind and /timer, and a signed-in person\'s saved commands) and its `@` menu is `GET /api/frame/mentions?q=&site=` (public people first, then the person\'s agents and OpenVibe places, at most eight).';
function llmsTxt() {
const { catalog } = toolsCatalog.peek();
@@@ -590,7 +590,7 @@ function llmsTxt() {
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
{ title: 'Trust signals (developer docs)', url: 'https://openvibe.network/docs/trust-signals', note: 'privacy-limited account and guest trust signals, API examples and fields' },
-- { title: 'The composer\'s Frame (developer docs)', url: 'https://openvibe.network/docs/frame', note: 'GET /api/frame/choices for the model sheet (Exchange prices, speed and pages) and POST /api/frame/ask for an answer with citations streamed as AG-UI on GET /api/v1/runs/:id/events' },
++ { title: 'The composer\'s Frame (developer docs)', url: 'https://openvibe.network/docs/frame', note: 'GET /api/frame/choices for the model sheet (Exchange prices, speed and pages), POST /api/frame/ask for an answer with citations streamed as AG-UI on GET /api/v1/runs/:id/events, GET /api/frame/commands for the / menu and GET /api/frame/mentions for the @ menu' },
{ title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read and network.my_trust.read' },
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },
ddiff --git a/test/frame-commands.test.js b/test/frame-commands.test.js
nnew file mode 100644
iindex 00000000..12bf1ce8
---- /dev/null
++++ b/test/frame-commands.test.js
@@@ -0,0 +1,102 @@
++'use strict';
++// GET /api/frame/commands: every site's /ask, /do, /remind and /timer, a site's own from its Frame manifest, and a
++// signed-in person's saved composer snippets. Site commands first, a name once, q filtering, and the cache rule
++// (signed out public for 60 s, signed in private and never kept).
++const assert = require('assert');
++const express = require('express');
++const { ids } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const { createFrameService } = require('../server/frame/service');
++const { read: readCommands } = require('../server/frame/commands');
++
++(async () => {
++ const db = getDb();
++ const subject = ids.newId('user');
++ const row = await db.prepare(`INSERT INTO users (username, display_name, email, password_hash, subject_id, profile_public)
++ VALUES ('sam', 'Sam Rivera', '[email protected]', 'x', ?, 1) RETURNING id`).get(subject);
++ const me = { id: Number(row.id), subject_id: subject, username: 'sam' };
++ await db.prepare(`INSERT INTO user_preferences (user_id, composer_prefs, composer_rev) VALUES (?, ?, 2)`)
++ .run(me.id, JSON.stringify({ snippets: [
++ { trigger: '/sig', label: 'Signature', text: '— Sam' },
++ { trigger: '/standup', label: 'Stand-up notes', text: 'Yesterday: ' },
++ { trigger: 'not a slash', label: 'bad', text: 'x' },
++ ] }));
++
++ const sites = [{
++ id: 'community', name: 'Community', host: 'openvibe.community', status: 'open', tagline: 'Posts and pastes',
++ commands: [
++ { name: 'poll', summary: 'Ask the room a question', insert: '/poll ', needs_sign_in: true },
++ { name: 'ask', hint: 'Ask the community', insert: '/ask ' },
++ { name: 'Bad Name', hint: 'not a slash word', insert: '/bad ' },
++ ],
++ }];
++
++ let clock = 1_000_000;
++ let who = null;
++ const svc = await createFrameService(db, { services: {} }, null, {
++ person: async () => who, sites, places: sites, now: () => clock, mentionLimit: 120,
++ });
++ const app = express();
++ app.set('trust proxy', 'loopback');
++ app.use('/api/frame', svc.router);
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/frame/commands`;
++ const get = async (qs, headers) => { const r = await fetch(`${base}${qs}`, { headers }); return { r, d: await r.json() }; };
++
++ try {
++ // Signed out: the site's own first (it replaces /ask), then the network's four; no saved commands.
++ let { r, d } = await get('?site=openvibe.community');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
++ assert.match(r.headers.get('vary') || '', /Authorization/);
++ assert.deepStrictEqual(d.site, { id: 'community', name: 'Community', host: 'openvibe.community' });
++ assert.deepStrictEqual(d.commands.map((c) => c.name), ['poll', 'ask', 'do', 'remind', 'timer'],
++ 'the site first, its /ask replaces the network one, then the network four');
++ assert.deepStrictEqual(Object.keys(d.commands[0]).sort(), ['hint', 'insert', 'kind', 'name', 'needs_sign_in']);
++ assert.deepStrictEqual([d.commands[0].kind, d.commands[0].needs_sign_in, d.commands[0].hint], ['site', true, 'Ask the room a question']);
++ assert.deepStrictEqual([d.commands[1].kind, d.commands[1].hint], ['site', 'Ask the community']);
++ assert.deepStrictEqual(d.commands.slice(2).map((c) => c.kind), ['network', 'network', 'network']);
++ assert.strictEqual(d.commands.find((c) => c.name === 'do').insert, '/do ');
++ assert.ok(!d.commands.some((c) => c.name === 'sig'), 'saved commands need a person');
++
++ // The same signed-out answer is held 60 s, then the site manifest is read again.
++ sites[0].commands.push({ name: 'clip', hint: 'Make a clip' });
++ ({ d } = await get('?site=openvibe.community'));
++ assert.ok(!d.commands.some((c) => c.name === 'clip'), 'the signed-out answer is held 60 s');
++ clock += 61_000;
++ ({ d } = await get('?site=openvibe.community'));
++ assert.ok(d.commands.some((c) => c.name === 'clip'), 'after the minute the manifest is read again');
++
++ // q filters by the name and the hint; an unknown site still offers the network four.
++ ({ d } = await get('?site=community&q=poll'));
++ assert.deepStrictEqual(d.commands.map((c) => c.name), ['poll']);
++ ({ d } = await get('?site=community&q=SIGN'));
++ assert.deepStrictEqual(d.commands, [], 'a query that matches no name or hint is an empty list');
++ ({ d } = await get('?site=nope.example'));
++ assert.strictEqual(d.site, null);
++ assert.deepStrictEqual(d.commands.map((c) => c.name), ['ask', 'do', 'remind', 'timer']);
++
++ // Signed in: the person's own snippets come last, and nothing is kept.
++ who = me;
++ ({ r, d } = await get('?site=openvibe.community'));
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ assert.deepStrictEqual(d.commands.map((c) => c.name), ['poll', 'ask', 'clip', 'do', 'remind', 'timer', 'sig', 'standup']);
++ const mine = d.commands.find((c) => c.name === 'sig');
++ assert.deepStrictEqual(mine, { name: 'sig', hint: 'Signature', insert: '— Sam', kind: 'mine', needs_sign_in: false });
++ assert.ok(!d.commands.some((c) => c.name === 'not a slash'), 'an invalid snippet is not a command');
++ ({ d } = await get('?site=community&q=standup'));
++ assert.deepStrictEqual(d.commands.map((c) => c.name), ['standup']);
++
++ // The pure read is the same rule: an invalid manifest command is dropped.
++ const pure = readCommands({ site: 'community', sites, siteForHost: () => null });
++ assert.ok(!pure.commands.some((c) => c.name === 'bad name'));
++ assert.strictEqual(pure.commands[0].name, 'poll');
++
++ console.log(' ok frame commands: site, network and saved rows; signed-out cache; q\n');
++ } finally {
++ await new Promise((r2) => server.close(r2));
++ svc.stop();
++ }
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/frame-mentions.test.js b/test/frame-mentions.test.js
nnew file mode 100644
iindex 00000000..eeda6bd3
---- /dev/null
++++ b/test/frame-mentions.test.js
@@@ -0,0 +1,162 @@
++'use strict';
++// GET /api/frame/mentions: public people first (avatar only from openvibe.media), then the person's own agents from
++// the agent directory, then open sites and Live's public channels. At most eight; signed out it is public people only.
++// The per-actor limit, the signed-out 60 s cache and the absence of any private account field are checked too.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { ids } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const { createFrameService } = require('../server/frame/service');
++
++(async () => {
++ const db = getDb();
++ const addUser = async (username, extra = {}) => {
++ const subject = ids.newId('user');
++ const row = await db.prepare(`INSERT INTO users (username, display_name, email, password_hash, subject_id, is_anon, is_banned, profile_public, avatar_url)
++ VALUES (?, ?, ?, 'x', ?, ?, ?, ?, ?) RETURNING id`)
++ .get(username, extra.display || username, extra.email || null, subject, extra.anon ? 1 : 0, extra.banned ? 1 : 0,
++ extra.public === false ? 0 : 1, extra.avatar || null);
++ return { id: Number(row.id), subject_id: subject, username, display_name: extra.display || username };
++ };
++ const me = await addUser('sam', { display: 'Sam Rivera', email: '[email protected]' });
++ const ana = await addUser('ana', { display: 'Ana Followed', email: '[email protected]', avatar: 'https://openvibe.media/p/ana/screenshot' });
++ const anabel = await addUser('anabel', { display: 'Anabel Also', email: '[email protected]' });
++ await addUser('zoe', { display: 'Zoe Public', email: '[email protected]', avatar: 'https://openvibe.network/avatar/zoe?s=64' });
++ await addUser('hidden', { display: 'Hidden Person', public: false });
++ await addUser('banned', { display: 'Banned Person', banned: true });
++ await addUser('anon', { display: 'Anon Person', anon: true });
++ const other = await addUser('other', { display: 'Other Person' });
++ await db.prepare('INSERT INTO follows (follower_id, followed_id) VALUES (?, ?)').run(me.id, ana.id);
++ await db.prepare('INSERT INTO follows (follower_id, followed_id) VALUES (?, ?)').run(me.id, anabel.id);
++
++ const agent = async (owner, name, kind, status) => {
++ const id = ids.newId('agent');
++ await db.prepare(`INSERT INTO agent_directory (id, owner_subject, kind, name, service, skills, status)
++ VALUES (?, ?, ?, ?, ?, '[]', ?)`).run(id, owner.subject_id, kind, name, kind, status);
++ return id;
++ };
++ const actor = await agent(me, 'Actor', 'actor', 'online');
++ await agent(me, 'Codes', 'codes', 'offline');
++ await agent(other, 'Other Agent', 'actor', 'online');
++
++ const live = http.createServer((req, res) => {
++ res.setHeader('content-type', 'application/json');
++ res.end(JSON.stringify({ streams: [
++ { username: 'streamer', display_name: 'Streamer One', avatar_url: 'https://openvibe.media/p/streamer/screenshot', is_public: true },
++ { username: 'secret', display_name: 'Secret Channel', visibility: 'private' },
++ { username: 'unlisted', display_name: 'Unlisted Channel', unlisted: true },
++ { username: 'bad name', display_name: 'Bad Channel', is_public: true },
++ ] }));
++ });
++ await new Promise((r) => live.listen(0, '127.0.0.1', r));
++
++ const places = [
++ { id: 'community', name: 'Community', host: 'openvibe.community', status: 'open', tagline: 'Posts and pastes' },
++ { id: 'live', name: 'Live', host: 'openvibe.live', status: 'open', tagline: 'Go live, watch streams' },
++ { id: 'network', name: 'Network', host: 'openvibe.network', status: 'open', tagline: 'Your account' },
++ { id: 'soon', name: 'Soon', host: 'openvibe.soon', status: 'soon' },
++ ];
++
++ let clock = 1_000_000;
++ let who = null;
++ const svc = await createFrameService(db, { services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } }, null, {
++ person: async () => who, places, sites: places, now: () => clock, mentionLimit: 50,
++ });
++ const app = express();
++ app.set('trust proxy', 'loopback');
++ app.use('/api/frame', svc.router);
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/frame/mentions`;
++ const get = async (qs) => { const r = await fetch(`${base}${qs}`); const text = await r.text(); return { r, text, d: JSON.parse(text) }; };
++
++ try {
++ // Signed out: public people only, in the mention row shape; the 60 s public cache.
++ let { r, text, d } = await get('?q=ana');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
++ assert.match(r.headers.get('vary') || '', /Authorization/);
++ assert.deepStrictEqual(d.mentions.map((m) => m.handle), ['ana', 'anabel'], 'an exact username first, then the rest');
++ assert.deepStrictEqual(d.mentions[0], {
++ kind: 'person', id: ana.subject_id, label: 'Ana Followed', handle: 'ana',
++ avatar: 'https://openvibe.media/p/ana/screenshot', url: 'https://openvibe.network/@ana',
++ });
++ assert.ok(d.mentions.every((m) => m.kind === 'person'), 'signed out has no agents or places');
++
++ ({ d } = await get('?q=zoe'));
++ assert.strictEqual(d.mentions.length, 1);
++ assert.ok(!('avatar' in d.mentions[0]), 'a non-Media avatar is never returned');
++ assert.ok(!/zoe@example\.test|sam@example\.test/.test(text), 'no email');
++
++ for (const q of ['hidden', 'banned', 'anon']) {
++ ({ d } = await get(`?q=${q}`));
++ assert.deepStrictEqual(d.mentions, [], `${q} is not a public person to mention`);
++ }
++
++ // Signed in: people first, then the person's agents (never another owner's), then places and channels.
++ who = me;
++ ({ r, d } = await get('?q=actor'));
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ assert.deepStrictEqual(d.mentions.map((m) => [m.kind, m.id]), [['agent', actor]]);
++ assert.strictEqual(d.mentions[0].label, 'Actor');
++ assert.match(d.mentions[0].sub, /actor.*online/i);
++ assert.ok(!text.includes(other.subject_id) && !text.includes('Other Agent'), "another owner's agent is not listed");
++
++ ({ d } = await get('?q=live'));
++ assert.ok(d.mentions.some((m) => m.kind === 'site' && m.id === 'live' && m.url === 'https://openvibe.live/'));
++ assert.ok(!d.mentions.some((m) => m.id === 'soon'), 'a site that is not open is not a place');
++
++ ({ d } = await get('?q=streamer'));
++ assert.deepStrictEqual(d.mentions.map((m) => [m.kind, m.id]), [['channel', 'streamer']]);
++ assert.strictEqual(d.mentions[0].url, 'https://openvibe.live/@streamer');
++ assert.strictEqual(d.mentions[0].avatar, 'https://openvibe.media/p/streamer/screenshot');
++ ({ d } = await get('?q=secret'));
++ assert.deepStrictEqual(d.mentions, [], 'a private Live channel is never mentioned');
++ ({ d } = await get('?q=unlisted'));
++ assert.deepStrictEqual(d.mentions, [], 'an unlisted Live channel is never mentioned');
++
++ ({ d } = await get('?site=openvibe.community&q=community'));
++ assert.ok(!d.mentions.some((m) => m.id === 'community'), 'the site the box is on is not suggested back');
++
++ ({ d } = await get('?q='));
++ assert.ok(d.mentions.length <= 8, 'at most eight rows');
++ const rank = { person: 0, agent: 1, site: 2, channel: 3 };
++ const ranks = d.mentions.map((m) => rank[m.kind]);
++ assert.deepStrictEqual(ranks, [...ranks].sort((a, b) => a - b), 'people first, then agents, then places');
++ assert.ok(!/@example\.test/.test(JSON.stringify(d)), 'no email in the signed-in answer');
++ assert.ok(!/password|owner_subject|machine_id|endpoint|skills/.test(JSON.stringify(d)), 'no private agent field');
++
++ // The per-actor limit is in memory and resets on the next minute; Retry-After says when.
++ const limited = await createFrameService(db, { services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } }, null, {
++ person: async () => me, places, sites: places, now: () => clock, mentionLimit: 2,
++ });
++ const app2 = express();
++ app2.set('trust proxy', 'loopback');
++ app2.use('/api/frame', limited.router);
++ const server2 = app2.listen(0, '127.0.0.1');
++ await new Promise((r2) => server2.once('listening', r2));
++ const base2 = `http://127.0.0.1:${server2.address().port}/api/frame/mentions`;
++ try {
++ assert.strictEqual((await fetch(`${base2}?q=a`)).status, 200);
++ assert.strictEqual((await fetch(`${base2}?q=b`)).status, 200);
++ const third = await fetch(`${base2}?q=c`);
++ assert.strictEqual(third.status, 429);
++ assert.ok(Number(third.headers.get('retry-after')) >= 1);
++ assert.strictEqual((await third.json()).code, 'frame.mentions_rate_limited');
++ clock += 61_000;
++ assert.strictEqual((await fetch(`${base2}?q=d`)).status, 200, 'the window is a minute');
++ } finally {
++ await new Promise((r2) => server2.close(r2));
++ limited.stop();
++ }
++
++ console.log(' ok frame mentions: people, agents, places, ranking, signed-out cache and the per-actor limit\n');
++ } finally {
++ await new Promise((r2) => server.close(r2));
++ live.close();
++ svc.stop();
++ }
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex e3670dc1..d2e996e9 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -133,6 +133,7 @@ const REVIEWED = {
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
++ 'server/frame/mentions.js fetch': [1, 'the composer\'s @ menu: Live (OV_LIVE_INTERNAL_URL / config.services.live) from operator config, the fixed /api/streams public list path, no request value names a host, redirects not followed, 2.5 s timeout; only public rows are read and no token is sent'],
'server/frame/service.js fetch': [8, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s); the AG-UI run stream relay uses a fixed /api/v1/runs/:id/events path, a validated task id, the trusted service URL, the person\'s Bearer, and no redirects'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],