openvibers/openvibe.network Commits

The action feed speaks in words, and the Dock keeps the choice you made (#712)

OP OpenVibers committed 4 days ago · d4490e2754 · 5 files, +145 −10

* The action feed speaks in words, and the Dock keeps the choice you made

A failed run used to carry the provider's own JSON to the screen. The feed now
answers with ai.run-error@1: a code, words, the next step and the action, and the
row also carries the skill, the subject, the model, the maker, the cost, the
duration and the live step of the run.

A failed run can be retried through the service that owns it, and a catalogue
skill can start as the person with one tap: Network asks Actor to run it, under
that person's own approvals and budget.

The Dock's synced state keeps the Vibe conversation a person chose and the Agents
filter they left it on, so both follow them across pages and sites.

* The skill a person starts is asked for with its own capability

Starting a catalogue skill is not the same right as asking for a task, so it is
asked for as actor.skill.run and Actor checks that one.

* Name the shared code map as the source of truth

The words a failed run shows come from Contracts' ai.run-error@1. The copy here is
what this service answers with until the release that carries the manifest is
pinned.

* A failed AI run can be retried through the service that owns it

Network already retries a task on Actor; a run on AI is retried the same way.

* Merge remote-tracking branch 'origin/main' into auto/dock-v2-network

* Outbound inventory: the retry and catalogue-skill calls, reviewed

* The Dock's one-tap skills come through Network: Actor's catalogue, passed and active only, kept five minutes

Changes

ddiff --git a/server/actions/index.js b/server/actions/index.js
iindex c7754654..0578762c 100644
---- a/server/actions/index.js
++++ b/server/actions/index.js
@@@ -34,6 +34,10 @@ const DECIDE = {
const CANCEL = {
actor: { cap: 'actor.task.create', path: (id) => '/api/v1/tasks/' + encodeURIComponent(id) + '/cancel', name: 'OpenVibe.Actor', what: 'task' },
};
++const RETRY = {
++ actor: { cap: 'actor.task.create', path: (id) => '/api/v1/tasks/' + encodeURIComponent(id) + '/retry', name: 'OpenVibe.Actor' },
++ ai: { cap: 'ai.run.create', path: (id) => '/api/v1/runs/' + encodeURIComponent(id) + '/retry', name: 'OpenVibe.AI' },
++};
const ACTIONS_TIMEOUT_MS = 2500;
const CACHE_MS = 10_000;
const CACHE_MAX = 500;
@@@ -41,6 +45,28 @@ const PER_BUCKET = 50;
const ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
++// The shared code map (Contracts' ai.run-error@1, manifests/ai-run-error-codes.v1.json) is the source of truth; this
++// copy is what Network answers with until the Contracts release that carries the manifest is pinned here.
++const ERROR_MAP = {
++ 'route.unavailable': ['No model could take this task.', 'Pick a model or add an AI key, then retry.', 'pick_model'],
++ 'provider.unavailable': ['The model provider is unavailable.', 'Retry when it is back.', 'retry'],
++ 'provider.rate_limited': ['The model provider reached its limit.', 'Retry after the limit resets.', 'retry_later'],
++ 'credentials.unavailable': ['Your AI key is unavailable.', 'Add or check your AI key.', 'add_key'],
++ 'quota.refused': ['Your AI allowance is used up.', 'Pick another model or retry when it resets.', 'pick_model'],
++ 'queue.full': ['AI is busy right now.', 'Retry in a moment.', 'retry_later'],
++ 'run.interrupted': ['This run stopped during a service restart.', 'Retry the task.', 'retry'],
++ 'task.failed': ['This task could not finish.', 'Open the task to review it, then retry.', 'open'],
++};
++function safeItem(it) {
++ const detail = String(it.error || it.summary || '');
++ const code = it.run_error && it.run_error.code || (detail.includes('no provider on this route') ? 'route.unavailable'
++ : detail.includes('AI is resting') ? 'provider.rate_limited' : it.service === 'actor' ? 'task.failed' : 'unknown');
++ const [words, next, action] = ERROR_MAP[code] || ['This run could not finish.', 'Open its details or retry.', 'open'];
++ const run_error = it.bucket === 'failed' ? { code, words, next, action } : null;
++ return { ...it, error: run_error ? words : it.error, summary: run_error ? words : it.summary, run_error,
++ skill: it.skill || null, subject: it.subject || null, model: it.model || null, maker: it.maker || null,
++ duration_ms: it.duration_ms == null ? null : it.duration_ms, step: it.step || null };
++}
/** A pending Network confirmation as an Action Center item (waiting). */
function confirmationItem(c, agent) {
@@@ -98,11 +124,12 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
['network', ...REMOTE].forEach((svc, i) => {
sources[svc] = results[i].status;
for (const it of results[i].items) {
-- if (!validate('common.action-item@1', it).valid) {
++ const { run_error, skill, subject, model, maker, duration_ms, step, ...legacy } = it;
++ if (!validate('common.action-item@1', legacy).valid) {
if (warned++ < 20) log.warn(`[Actions] ${svc} answered an item that is not common.action-item@1; dropped`);
continue;
}
-- buckets[it.bucket].push(it);
++ buckets[it.bucket].push(safeItem(it));
}
});
for (const b of BUCKETS) {
@@@ -121,8 +148,9 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
/** Approve, decline or cancel one item through its authority. → common.action-item@1 */
async function act(actor, service, id, action, ctx) {
if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such item'), { status: 404, code: 'actions.not_found' });
-- if (action !== 'approve' && action !== 'decline' && action !== 'cancel') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
++ if (action !== 'approve' && action !== 'decline' && action !== 'cancel' && action !== 'retry') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
if (action === 'cancel') return await cancel(actor, service, id, ctx);
++ if (action === 'retry') return await retry(actor, service, id);
cache.delete(actor.subject);
if (service === 'network') {
const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });
@@@ -169,7 +197,58 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
};
}
-- return { forPerson, act, _cache: cache };
++ async function retry(actor, service, id) {
++ const route = RETRY[service];
++ if (!route) throw Object.assign(new Error('this run cannot retry here'), { status: 422, code: 'actions.unsupported' });
++ const token = urls[service] ? selfToken('openvibe.' + service, [route.cap]) : null;
++ if (!token) throw Object.assign(new Error(route.name + ' is not connected'), { status: 503, code: 'actions.not_connected' });
++ const res = await fetchImpl(String(urls[service]).replace(/\/+$/, '') + route.path(id), {
++ method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-ov-subject': actor.subject },
++ body: '{}', signal: AbortSignal.timeout(timeoutMs * 4),
++ });
++ const body = await res.json().catch(() => ({}));
++ if (!res.ok) throw Object.assign(new Error(body.detail || 'This run could not restart.'), { status: res.status, code: body.code || 'actions.retry_failed' });
++ cache.delete(actor.subject);
++ return body;
++ }
++
++ /**
++ * The skills the Dock offers in one tap ("Things agents can do for you"): Actor's public catalogue, read here because
++ * every site's connect-src reaches openvibe.network and refuses openvibe.actor. Only passed, active listings, only
++ * the fields the Dock draws; kept five minutes; Actor not answering is an empty list.
++ */
++ let skillsKept = null; let skillsAt = 0;
++ async function skillCatalogue() {
++ if (skillsKept && now() - skillsAt < 5 * 60_000) return skillsKept;
++ if (!urls.actor) return { listings: [] };
++ try {
++ const res = await fetchImpl(`${String(urls.actor).replace(/\/+$/, '')}/api/v1/skills`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
++ const body = res.ok ? await res.json().catch(() => ({})) : {};
++ const listings = (Array.isArray(body.listings) ? body.listings : [])
++ .filter((x) => x && x.skill && ID_RE.test(String(x.id || '')) && x.scan && x.scan.state === 'passed' && x.curator && x.curator.state === 'active')
++ .slice(0, 12)
++ .map((x) => ({ id: String(x.id), skill: { name: String(x.skill.name || '').slice(0, 80), slug: String(x.skill.slug || '').slice(0, 80) }, scan: { state: 'passed' }, curator: { state: 'active' } }));
++ skillsKept = { listings }; skillsAt = now();
++ } catch { skillsKept = skillsKept || { listings: [] }; skillsAt = now(); }
++ return skillsKept;
++ }
++
++ /** A one-tap catalogue skill starts on Actor as this person; Network never executes the skill itself. */
++ async function runSkill(actor, id, body) {
++ if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such skill'), { status: 404, code: 'actions.skill_not_found' });
++ const token = urls.actor ? selfToken('openvibe.actor', ['actor.skill.run']) : null;
++ if (!token) throw Object.assign(new Error('Actor is not connected'), { status: 503, code: 'actions.not_connected' });
++ const res = await fetchImpl(`${String(urls.actor).replace(/\/+$/, '')}/api/v1/runs`, {
++ method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${token}`, 'x-ov-subject': actor.subject },
++ body: JSON.stringify({ skill: id, input: body.input || {}, choice: body.choice || { mode: 'balanced' } }), signal: AbortSignal.timeout(timeoutMs * 4),
++ });
++ const result = await res.json().catch(() => ({}));
++ if (!res.ok) throw Object.assign(new Error(result.detail || 'This skill could not start.'), { status: res.status, code: result.code || 'actions.skill_failed' });
++ cache.delete(actor.subject);
++ return result;
++ }
++
++ return { forPerson, act, runSkill, skillCatalogue, _cache: cache };
}
/** The routes, behind Network's session check (cookie on openvibe.network, Bearer elsewhere), like the bell's. */
@@@ -187,6 +266,14 @@ function router({ actions, requireAuth, requireDock = () => requireAuth }) {
http.sendProblem(res, 500, 'actions.error', { detail: 'could not read your actions' });
}
});
++ r.get('/skills', requireAuth, async (req, res) => { res.json(await actions.skillCatalogue()); });
++ r.post('/skills/:id/run', requireAuth, async (req, res) => {
++ try {
++ const actor = await store.actorOf(req.app.locals.db, req.user);
++ const result = await actions.runSkill(actor, req.params.id, req.body || {});
++ return res.status(201).json(result);
++ } catch (err) { return http.sendProblem(res, err.status || 502, err.code || 'actions.skill_failed', { detail: err.message }); }
++ });
r.post('/:service/:id/:action', requireAuth, async (req, res) => {
try {
const actor = await store.actorOf(req.app.locals.db, req.user);
ddiff --git a/server/dock/index.js b/server/dock/index.js
iindex fef448e1..c25e9e62 100644
---- a/server/dock/index.js
++++ b/server/dock/index.js
@@@ -44,6 +44,19 @@ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
++// Keep the new cross-site view fields usable while Network still runs the previous Contracts release. The base
++// contract checks every established field; these two fields have the same bounds as its additive schema update.
++function checkState(state) {
++ const { vibe, agent_filter, ...base } = state;
++ const check = validate(CONTRACT, base);
++ if (!check.valid) return check;
++ if (vibe !== undefined && vibe !== null && (!isObject(vibe) || Object.keys(vibe).some((k) => !['slug', 'at'].includes(k))
++ || !/^[A-Za-z0-9_-]{1,80}$/.test(String(vibe.slug || '')) || !Number.isFinite(Date.parse(vibe.at)) || typeof vibe.at !== 'string'))
++ return { valid: false, errors: [{ instancePath: '/vibe', message: 'must be a Vibe conversation and a date' }] };
++ if (agent_filter !== undefined && !['all', 'running', 'waiting', 'scheduled', 'done', 'failed'].includes(agent_filter))
++ return { valid: false, errors: [{ instancePath: '/agent_filter', message: 'must be an Agents filter' }] };
++ return check;
++}
/** The default state, for a person who has never opened the Dock. */
function defaultState(at) {
@@@ -138,7 +151,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
if (typeof state === 'string') { try { state = JSON.parse(state); } catch { state = null; } }
// A row written by an older release (or by hand) is still shown, but only if it is a state the contract
// accepts: a broken row falls back to the default rather than breaking the page that reads it.
-- return state && validate(CONTRACT, state).valid ? state : defaultState(at);
++ return state && checkState(state).valid ? state : defaultState(at);
}
/** The counts the pill's status dot is drawn from. */
@@@ -202,7 +215,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
}
const current = await stateOf(actor, at);
const merged = { ...mergeState(current, body), updated_at: new Date(at).toISOString() };
-- const check = validate(CONTRACT, merged);
++ const check = checkState(merged);
if (!check.valid) {
throw Object.assign(new Error('that is not a Dock state'), { status: 422, code: 'dock.invalid', errors: check.errors });
}
@@@ -227,7 +240,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
if (bad) throw Object.assign(new Error(bad.error), { status: 422, code: 'dock.invalid', errors: bad.errors });
const current = await stateOf(actor, at);
const merged = { ...current, timer: timerOf(body, at), updated_at: new Date(at).toISOString() };
-- const check = validate(CONTRACT, merged);
++ const check = checkState(merged);
if (!check.valid) {
throw Object.assign(new Error('that is not a Dock state'), { status: 422, code: 'dock.invalid', errors: check.errors });
}
ddiff --git a/test/dock.test.js b/test/dock.test.js
iindex a07d50d8..e72e73d7 100644
---- a/test/dock.test.js
++++ b/test/dock.test.js
@@@ -133,6 +133,14 @@ try {
assert.strictEqual(r.body.state.agent, null, 'null clears the agent');
assert.strictEqual(r.body.state.chat, 'cnv_1', 'the chat was not in the body and stayed');
++ // The Vibe conversation and the Agents filter: the choice a person made follows them across sites.
++ r = await call('PUT', { vibe: { slug: 'v-abc', at: '2026-11-09T08:00:00.000Z' }, agent_filter: 'failed' });
++ assert.deepStrictEqual(r.body.state.vibe, { slug: 'v-abc', at: '2026-11-09T08:00:00.000Z' });
++ assert.strictEqual(r.body.state.agent_filter, 'failed');
++ r = await call('PUT', { vibe: null });
++ assert.strictEqual(r.body.state.vibe, null, 'null clears the conversation');
++ assert.strictEqual(r.body.state.agent_filter, 'failed', 'the filter was not in the body and stayed');
++
// An invalid body is refused with the contract's problem shape.
for (const [body, why] of [
[{ tab: 'profile' }, 'an unknown tab'],
@@@ -141,6 +149,9 @@ try {
[{ theme: 'neon-tokyo' }, 'a key the contract does not know'],
[{ place: { tablet: { corner: 'top-left' } } }, 'a device class the contract does not know'],
[{ chat: '' }, 'an empty chat id'],
++ [{ vibe: { slug: 'not a slug', at: '2026-11-09T08:00:00.000Z' } }, 'a conversation slug with spaces'],
++ [{ vibe: { slug: 'v-abc', at: 'yesterday' } }, 'a conversation with no time'],
++ [{ agent_filter: 'everything' }, 'an Agents filter the Dock does not know'],
]) {
const before = (await call('GET')).body.state;
r = await call('PUT', body);
ddiff --git a/test/me-actions.test.js b/test/me-actions.test.js
iindex b07cc379..1fea4773 100644
---- a/test/me-actions.test.js
++++ b/test/me-actions.test.js
@@@ -31,6 +31,7 @@ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_v
const calls = [];
let aiDelay = 0;
let cancelled = false;
++let skillReads = 0;
const svc = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
@@@ -38,6 +39,12 @@ const svc = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
calls.push({ method: req.method, path: u.pathname, subject: u.searchParams.get('subject') || req.headers['x-ov-subject'] || null, auth: req.headers.authorization, body: body ? JSON.parse(body) : null });
const json = (s, b) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(b)); };
++ if (u.pathname === '/actor/api/v1/skills') { skillReads++; return json(200, { listings: [
++ { id: 'skl_ok', skill: { name: 'Research brief', slug: 'research-brief', instructions: 'secret prompt' }, scan: { state: 'passed' }, curator: { state: 'active' } },
++ { id: 'skl_flagged', skill: { name: 'Flagged', slug: 'flagged' }, scan: { state: 'failed' }, curator: { state: 'active' } },
++ { id: '../x', skill: { name: 'Odd id', slug: 'odd' }, scan: { state: 'passed' }, curator: { state: 'active' } },
++ ] }); }
++ if (u.pathname === '/actor/api/v1/runs' && req.method === 'POST') return json(201, { run: { id: 'tsk_new', skill: 'research-brief', subject: 'Research brief' } });
if (u.pathname === '/actor/api/v1/actions') return json(200, { items: [
item(cancelled ? { state: 'cancelled', bucket: 'failed', actions: ['open'], updated_at: iso(1) } : {}),
item({ id: 'apr_1', kind: 'approval', title: 'Spend $0.40 on a desk hour', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(5), href: 'https://openvibe.actor/me/approvals' }),
@@@ -54,6 +61,7 @@ const svc = http.createServer((req, res) => {
if (u.pathname === '/actor/api/v1/approvals/apr_1/decide') return json(200, item({ id: 'apr_1', kind: 'approval', bucket: 'done', state: 'approved', actions: ['open'] }));
if (u.pathname === '/actor/api/v1/approvals/apr_404/decide') return json(404, { code: 'not_found' });
if (u.pathname === '/actor/api/v1/tasks/tsk_1/cancel') { cancelled = true; return json(200, { id: 'tsk_1', state: 'cancelled' }); }
++ if (u.pathname === '/actor/api/v1/tasks/tsk_old/retry') return json(201, { run: { id: 'tsk_retry', state: 'queued' } });
if (u.pathname === '/actor/api/v1/tasks/tsk_404/cancel') return json(404, { code: 'not_found' });
if (u.pathname === '/domains/api/v1/actions') return json(200, { items: [item({ service: 'domains', id: 'dpr_1', kind: 'approval', title: 'Add a TXT record to example.com', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(20), href: 'https://openvibe.domains/panel/p/dpr_1' })] });
if (u.pathname === '/domains/api/v1/proposals/dpr_1/decide') return json(200, item({ service: 'domains', id: 'dpr_1', kind: 'approval', bucket: 'done', state: 'applied', actions: ['open'], href: 'https://openvibe.domains/panel/p/dpr_1' }));
@@@ -88,7 +96,7 @@ app.use('/api/v1/me/actions', actionsMod.router({ actions, requireAuth }));
const server = http.createServer(app);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const api = `http://127.0.0.1:${server.address().port}/api/v1/me/actions`;
--const call = (method, path = '', auth = true) => fetch(api + path, { method, headers: auth ? { authorization: `Bearer ${token}` } : {} }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
++const call = (method, path = '', auth = true, body = null) => fetch(api + path, { method, headers: { ...(auth ? { authorization: `Bearer ${token}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
try {
assert.strictEqual((await call('GET', '', false)).status, 401, 'a session is needed');
@@@ -97,7 +105,8 @@ try {
let r = await call('GET');
assert.strictEqual(r.status, 200);
const doc = r.body;
-- assert.ok(validate('network.me-actions-result@1', doc).valid, JSON.stringify(validate('network.me-actions-result@1', doc).errors));
++ const legacyDoc = { ...doc, buckets: Object.fromEntries(Object.entries(doc.buckets).map(([name, items]) => [name, items.map(({ run_error, skill, subject, model, maker, duration_ms, step, ...legacy }) => legacy)])) };
++ assert.ok(validate('network.me-actions-result@1', legacyDoc).valid, JSON.stringify(validate('network.me-actions-result@1', legacyDoc).errors));
assert.deepStrictEqual(doc.sources, { network: 'ok', actor: 'ok', codes: 'not_connected', ai: 'timeout', domains: 'ok' });
assert.strictEqual(doc.partial, true, 'a timeout makes the answer partial');
assert.deepStrictEqual(doc.counts, { running: 1, waiting: 5, scheduled: 1, done: 0, failed: 1 });
@@@ -155,6 +164,21 @@ try {
const dc = calls.find((c) => c.path === '/actor/api/v1/tasks/tsk_1/cancel');
assert.deepStrictEqual([dc.method, dc.auth, dc.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.actor:actor.task.create', true]);
assert.deepStrictEqual([r.body.item.state, r.body.item.bucket], ['cancelled', 'failed'], 'the item comes back as it is now');
++ r = await call('POST', '/actor/tsk_old/retry');
++ assert.strictEqual(r.body.item.run.id, 'tsk_retry');
++ const retried = calls.find((c) => c.path === '/actor/api/v1/tasks/tsk_old/retry');
++ assert.deepStrictEqual([retried.auth, retried.subject.startsWith('usr_')], ['Bearer tok:openvibe.actor:actor.task.create', true]);
++ r = await call('POST', '/skills/skl_example/run', true, { input: {}, choice: { mode: 'balanced' } });
++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
++ const started = calls.find((c) => c.path === '/actor/api/v1/runs');
++ assert.deepStrictEqual([started.auth, started.subject.startsWith('usr_'), started.body.skill], ['Bearer tok:openvibe.actor:actor.skill.run', true, 'skl_example']);
++ // The Dock's one-tap skills come through Network (every site's connect-src refuses openvibe.actor): passed, active,
++ // plain ids, the fields the Dock draws only (never a skill's instructions), kept five minutes.
++ r = await call('GET', '/skills');
++ assert.deepStrictEqual(r.body.listings.map((x) => x.id), ['skl_ok']);
++ assert.ok(!JSON.stringify(r.body).includes('secret prompt'), 'a skill\'s instructions never leave Actor here');
++ await call('GET', '/skills');
++ assert.strictEqual(skillReads, 1, 'the catalogue is kept');
assert.strictEqual((await call('POST', '/actor/tsk_404/cancel')).status, 404, 'no such task');
// Codes has no cancel route, and Codes is not connected for it either.
assert.strictEqual((await call('POST', '/codes/tsk_1/approve')).body.code, 'actions.unsupported');
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 738a447c..ace41228 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -135,7 +135,7 @@ const REVIEWED = {
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/node/send.js fetch': [3, 'browser-only Link Send requests to fixed same-origin machine-key and transfer routes; page input supplies file bytes, never a URL or destination host'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
-- 'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
++ 'server/actions/index.js fetch': [7, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
'server/profile/avatar.js fetch': [4, 'Live (push) and Media (ingest, release) internal URLs from config; a picture is never fetched here — Media\'s ingest fetches it (asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],