openvibers/openvibe.network Commits

The Action Center (/api/v1/me/actions) and OpenVibe.Today live; contracts v0.230.0, openvibe-shared v4.31.0 (#396)

OP OpenVibers committed 16 days ago · 76c4d4e167 · 12 files, +347 −39

* The Action Center: GET /api/v1/me/actions merges the person's confirmations with Actor's, Codes' and AI's items into five buckets (network.me-actions-result@1), and approve/decline act through the owning authority; contracts v0.229.0

* OpenVibe.Today is live: the today OAuth client signs people in at openvibe.today, its grants (publish today.event.*, subscribe, reminders as notifications, subject resolve), the domain leaves the parked list; contracts v0.230.0, openvibe-shared v4.31.0 (the ✦ Action Center)

Changes

ddiff --git a/README.md b/README.md
iindex 5bae5d70..919da6bf 100644
---- a/README.md
++++ b/README.md
@@@ -280,6 +280,20 @@ curl -X POST http://127.0.0.1:4000/internal/notifications/push \
-d '{"userId": 42, "type": "new_follower", "data": {"actorName": "someone"}}'
```
++### The Action Center
++
++`GET /api/v1/me/actions` (`server/actions/`, capability `network.actions.read`, contracts 0.229.0) is the ✦ beside the bell:
++the signed-in person's agent work in five buckets (`running`, `waiting`, `scheduled`, `done`, `failed`) as
++`network.me-actions-result@1`. It merges their pending confirmations (`server/developer/confirmations.js`) with
++Actor's, Codes' and AI's `GET /api/v1/actions?subject=usr_…` (`actor.actions.read`, `codes.actions.read`,
++`ai.actions.read`; Network signs its own token for each, `server/identity/self-token.js`; `OV_ACTOR_INTERNAL_URL`,
++`OV_CODES_INTERNAL_URL`, `OV_AI_INTERNAL_URL`). Each source has 2.5 s; a slow or failing one is named in `sources`
++and the answer is `partial`, and a service that has not deployed the route is `not_connected`. Items that are not
++`common.action-item@1`, or claim another service, are dropped. The answer is cached 10 s per person.
++`POST /api/v1/me/actions/:service/:id/approve|decline` decides a Network confirmation, or an Actor approval through
++Actor's `POST /api/v1/approvals/:id/decide` (`actor.approval.decide`, `X-OV-Subject`); anything else is opened on its
++own page.
++
### Notifications from Events
`POST /internal/events` (`server/notifications/events-consumer.js`) turns OpenVibe.Events deliveries
ddiff --git a/deploy/nginx/parked-domains.conf b/deploy/nginx/parked-domains.conf
iindex 2f877607..eda0519e 100644
---- a/deploy/nginx/parked-domains.conf
++++ b/deploy/nginx/parked-domains.conf
@@@ -81,28 +81,6 @@ server {
return 302 https://openvibe.deals/search?q=shoes;
}
--# openvibe.today → the network's front door (what's on now)
--server {
-- listen 80;
-- listen [::]:80;
-- server_name openvibe.today www.openvibe.today;
-- return 302 https://openvibe.network/;
--}
--
--server {
-- listen 443 ssl;
-- listen [::]:443 ssl;
-- http2 on;
-- server_name openvibe.today www.openvibe.today;
-- ssl_certificate /etc/letsencrypt/live/openvibe.today/fullchain.pem;
-- ssl_certificate_key /etc/letsencrypt/live/openvibe.today/privkey.pem;
-- add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
-- add_header X-Content-Type-Options "nosniff" always;
-- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
-- access_log off;
-- return 302 https://openvibe.network/;
--}
--
# openvibe.vision → AI, whose models read pictures
server {
listen 80;
ddiff --git a/package-lock.json b/package-lock.json
iindex 72e36aa6..9c0f9731 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,9 +20,9 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.228.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.230.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.30.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
@@@ -1320,9 +1320,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.228.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.228.0",
-- "integrity": "sha512-U80yv+sf5oQGYGxsx74Gs+Q0T9UE0IykupBDxjGcub2DnHFLJQgfOHy56DuxDhjScKJI8B/nKT+QMAB4JfbsvQ==",
++ "version": "0.230.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.230.0",
++ "integrity": "sha512-wvzSYV0GKfHV6UnEKtqDAXkrl3yimdWe/KHM0jeU0JWkrRo93zGNAZ2fRPwATBeihITVE0b973dM899U/YOzgw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1364,9 +1364,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.30.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.30.0",
-- "integrity": "sha512-eZ5MiqWCdSN9t8wC5D1T1d3OP2NuxAlUyqm/b4Sizm0qCwbgtJFKItUM/uJE2/AKppPxfGH246amMSuzem6roA==",
++ "version": "4.31.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
++ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 403460da..09f3908c 100644
---- a/package.json
++++ b/package.json
@@@ -24,9 +24,9 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.228.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.230.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.30.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
ddiff --git a/server/actions/index.js b/server/actions/index.js
nnew file mode 100644
iindex 00000000..ca796136
---- /dev/null
++++ b/server/actions/index.js
@@@ -0,0 +1,163 @@
++'use strict';
++/**
++ * The Action Center (plan 2026-10-28 §3.10, capability network.actions.read): one list of a person's agent work, read
++ * by the ✦ beside the bell on every OpenVibe site with the person's Network token.
++ *
++ * GET /api/v1/me/actions network.me-actions-result@1
++ * POST /api/v1/me/actions/:service/:id/:action approve | decline → { item } (common.action-item@1), or a problem
++ *
++ * Sources, asked in parallel for the person's subject:
++ * network their pending confirmations (server/developer/confirmations.js): waiting, approve or decline here;
++ * actor GET <actor>/api/v1/actions?subject= (actor.actions.read) agent tasks, approvals, scheduled agents;
++ * codes GET <codes>/api/v1/actions?subject= (codes.actions.read) coding tasks;
++ * ai GET <ai>/api/v1/actions?subject= (ai.actions.read) AI runs on their behalf.
++ * Network signs its own short tokens for each (server/identity/self-token.js). Each source has ACTIONS_TIMEOUT_MS; a
++ * slow, failing or not-yet-deployed one is left out and named in `sources` (partial: true), never failing the answer.
++ * Every item is validated against common.action-item@1; one that is not valid is dropped, never shown half-formed.
++ * The merged answer is kept CACHE_MS per person, and dropped when they act.
++ */
++const express = require('express');
++const { validate, http } = require('openvibe-contracts');
++const confirmations = require('../developer/confirmations');
++const store = require('../developer/store');
++
++const BUCKETS = ['running', 'waiting', 'scheduled', 'done', 'failed'];
++const REMOTE = ['actor', 'codes', 'ai'];
++const ACTIONS_TIMEOUT_MS = 2500;
++const CACHE_MS = 10_000;
++const CACHE_MAX = 500;
++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; };
++
++/** A pending Network confirmation as an Action Center item (waiting). */
++function confirmationItem(c, agent) {
++ const by = agent && (agent.name || agent.id);
++ return {
++ service: 'network', id: c.id, kind: 'approval',
++ title: clip(c.summary || c.capability, 160) || 'An approval',
++ summary: clip(by ? `${by} asks to use ${c.capability}` : `Asked to use ${c.capability}`, 400),
++ bucket: 'waiting', state: c.state, agent: by ? clip(by, 80) : null, cost_usd: null, steps: null, error: null,
++ created_at: new Date(c.created_at).toISOString(), updated_at: new Date(c.created_at).toISOString(), next_run_at: null,
++ href: 'https://openvibe.network/my#approvals', actions: ['approve', 'decline', 'open'],
++ };
++}
++
++function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...a) => fetch(...a), now = () => Date.now(), timeoutMs = ACTIONS_TIMEOUT_MS, log = console, confirmationsImpl = confirmations } = {}) {
++ const cache = new Map();
++ let warned = 0;
++
++ async function remote(service, subject) {
++ const base = urls[service];
++ const token = base ? selfToken(`openvibe.${service}`, [`${service}.actions.read`]) : null;
++ if (!base || !token) return { status: 'not_connected', items: [] };
++ try {
++ const res = await fetchImpl(`${String(base).replace(/\/+$/, '')}/api/v1/actions?subject=${encodeURIComponent(subject)}`, {
++ headers: { accept: 'application/json', authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(timeoutMs),
++ });
++ // A service that has not deployed the route yet answers 404: not connected, not an outage.
++ if (res.status === 404) return { status: 'not_connected', items: [] };
++ if (!res.ok) return { status: 'unavailable', items: [] };
++ const body = await res.json().catch(() => null);
++ const items = body && Array.isArray(body.items) ? body.items : [];
++ return { status: 'ok', items: items.filter((it) => it && it.service === service) };
++ } catch (err) {
++ return { status: err && (err.name === 'TimeoutError' || err.name === 'AbortError') ? 'timeout' : 'unavailable', items: [] };
++ }
++ }
++
++ async function local(actor) {
++ try {
++ const out = await confirmationsImpl.list(db, actor, { state: 'pending', limit: 50 });
++ return { status: 'ok', items: out.confirmations.map((c) => confirmationItem(c, out.agents[c.requested_by.id])) };
++ } catch (err) {
++ log.warn('[Actions] confirmations:', err.message);
++ return { status: 'unavailable', items: [] };
++ }
++ }
++
++ /** network.me-actions-result@1 for one person (an actorOf() actor). */
++ async function forPerson(actor) {
++ const hit = cache.get(actor.subject);
++ if (hit && now() - hit.at < CACHE_MS) return hit.doc;
++ const results = await Promise.all([local(actor), ...REMOTE.map((s) => remote(s, actor.subject))]);
++ const sources = {};
++ const buckets = Object.fromEntries(BUCKETS.map((b) => [b, []]));
++ ['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) {
++ if (warned++ < 20) log.warn(`[Actions] ${svc} answered an item that is not common.action-item@1; dropped`);
++ continue;
++ }
++ buckets[it.bucket].push(it);
++ }
++ });
++ for (const b of BUCKETS) {
++ buckets[b].sort((x, y) => (b === 'scheduled' ? String(x.next_run_at || '').localeCompare(String(y.next_run_at || '')) : String(y.updated_at).localeCompare(String(x.updated_at))));
++ buckets[b] = buckets[b].slice(0, PER_BUCKET);
++ }
++ const doc = {
++ buckets, counts: Object.fromEntries(BUCKETS.map((b) => [b, buckets[b].length])), sources,
++ partial: Object.values(sources).some((s) => s === 'timeout' || s === 'unavailable'), generated_at: new Date(now()).toISOString(),
++ };
++ cache.set(actor.subject, { at: now(), doc });
++ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
++ return doc;
++ }
++
++ /** Approve or decline one waiting 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') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
++ cache.delete(actor.subject);
++ if (service === 'network') {
++ const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });
++ return { ...confirmationItem(out.confirmation, null), bucket: 'done', actions: ['open'], updated_at: new Date(now()).toISOString() };
++ }
++ if (service !== 'actor') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
++ const token = urls.actor ? selfToken('openvibe.actor', ['actor.approval.decide']) : null;
++ if (!token) throw Object.assign(new Error('OpenVibe.Actor is not connected'), { status: 503, code: 'actions.not_connected' });
++ const res = await fetchImpl(`${String(urls.actor).replace(/\/+$/, '')}/api/v1/approvals/${encodeURIComponent(id)}/decide`, {
++ method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${token}`, 'x-ov-subject': actor.subject },
++ body: JSON.stringify({ decision: action }), signal: AbortSignal.timeout(timeoutMs * 2),
++ });
++ const body = await res.json().catch(() => ({}));
++ if (res.status === 404) throw Object.assign(new Error('no such approval'), { status: 404, code: 'actions.not_found' });
++ if (!res.ok) throw Object.assign(new Error((body && (body.detail || body.title)) || `OpenVibe.Actor answered ${res.status}`), { status: 502, code: 'actions.upstream' });
++ return body;
++ }
++
++ return { forPerson, act, _cache: cache };
++}
++
++/** The routes, behind Network's session check (cookie on openvibe.network, Bearer elsewhere), like the bell's. */
++function router({ actions, requireAuth }) {
++ const r = express.Router();
++ r.use(express.json({ limit: '4kb' }));
++ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
++ r.get('/', requireAuth, async (req, res) => {
++ try {
++ const actor = await store.actorOf(req.app.locals.db, req.user);
++ res.json(await actions.forPerson(actor));
++ } catch (err) {
++ if (err && err.status) return http.sendProblem(res, err.status, err.code || 'actions.error', { detail: err.message });
++ console.error('[Actions] list:', err);
++ http.sendProblem(res, 500, 'actions.error', { detail: 'could not read your actions' });
++ }
++ });
++ r.post('/:service/:id/:action', requireAuth, async (req, res) => {
++ try {
++ const actor = await store.actorOf(req.app.locals.db, req.user);
++ res.json({ item: await actions.act(actor, String(req.params.service), String(req.params.id), String(req.params.action), req.ov) });
++ } catch (err) {
++ if (err && err.status) return http.sendProblem(res, err.status, err.code || 'actions.error', { detail: err.message });
++ console.error('[Actions] act:', err);
++ http.sendProblem(res, 500, 'actions.error', { detail: 'could not do that' });
++ }
++ });
++ return r;
++}
++
++module.exports = { createActions, router, confirmationItem, BUCKETS, ACTIONS_TIMEOUT_MS, CACHE_MS };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 82225f5f..4504c0b8 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -122,6 +122,8 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'help', name: 'OpenVibe.Help', redirect_uris: ['https://openvibe.help/auth/callback'] },
// The theme studio (plan 2026-10-28 §5.2); the client is created first as Design's principal.
{ client_id: 'design', name: 'OpenVibe.Design', redirect_uris: ['https://openvibe.design/auth/callback'] },
++ // Schedules, reminders, calendar feeds and the brief (plan 2026-10-28 §5.3); created first as Today's principal.
++ { client_id: 'today', name: 'OpenVibe.Today', redirect_uris: ['https://openvibe.today/auth/callback'] },
{ client_id: 'work', name: 'OpenVibe.Work', redirect_uris: ['https://openvibe.work/auth/callback'] },
// The shared quest log, listings people post, and Watch's public site (plan T18 step 8, T19).
{ client_id: 'quest', name: 'OpenVibe.Quest', redirect_uris: ['https://openvibe.quest/auth/callback'] },
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex cdb3aae4..bdbf303e 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -27,7 +27,7 @@ const DEFAULT_GRANTS = [
['quest', 'identity.subject.resolve', SELF_AUDIENCE, []],
['quest', 'events.subscription.manage', 'openvibe.events', []],
// They subscribe to network.account.export_requested and network.account.deleted at boot (ADR-033).
-- ...['actor', 'food', 'help', 'work', 'rent', 'media-hub'].map((svc) => [svc, 'events.subscription.manage', 'openvibe.events', []]),
++ ...['actor', 'food', 'help', 'work', 'rent', 'media-hub', 'today'].map((svc) => [svc, 'events.subscription.manage', 'openvibe.events', []]),
// Their pages go into OpenVibe.Search: <svc>.index_document.* through each one's outbox (openvibe-publishing/search-feed,
// contracts 0.128.0). Inventory publishes already (below).
...['work', 'rent', 'help', 'quest'].map((svc) => [svc, 'events.event.publish', 'openvibe.events', []]),
@@@ -446,6 +446,11 @@ const DEFAULT_GRANTS = [
// Actor's agents search through the fabric (SF-4): search_web asks the web scope as Actor, naming the task's person
// in X-OV-Subject, so the paid steps count against that person's daily allowance.
['actor', 'search.query.delegate', 'openvibe.search', []],
++ // OpenVibe.Today (plan 2026-10-28 §5.3): today.event.* from its outbox, reminders as notifications to the person
++ // who set them (resolved from their usr_ subject), and its live.stream.* and account subscriptions.
++ ['today', 'events.event.publish', 'openvibe.events', []],
++ ['today', 'identity.subject.resolve', SELF_AUDIENCE, []],
++ ['today', 'network.notifications.push', SELF_AUDIENCE, ['today']],
// Support inboxes (Chat's server/inbox/, 2026-10-24): a visitor who asks for a person, or writes while waiting,
// notifies the inbox's people through Network as service 'chat' (the push route checks that by the token's own
// name; no namespace, so Chat's module namespaces stay the chat.* ones). Chat already resolves subjects.
ddiff --git a/server/index.js b/server/index.js
iindex 23208430..20fbbb24 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -622,6 +622,16 @@ require('./identity/grants-admin').start(db);
// Pending confirmations past expires_at are recorded expired every minute (reads report them expired before that).
require('./developer/confirmations').setNotifier(notificationService);
require('./developer/confirmations').start(db);
++// The Action Center (plan 2026-10-28 §3.10, server/actions): the person's agent work from Actor, Codes and AI and their
++// pending confirmations, in five buckets, for the ✦ beside the bell on every site.
++{
++ const actions = require('./actions').createActions({ db, selfToken, urls: {
++ actor: process.env.OV_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950',
++ codes: process.env.OV_CODES_INTERNAL_URL || 'http://127.0.0.1:4900',
++ ai: process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700',
++ } });
++ app.use('/api/v1/me/actions', rateLimit({ windowMs: 60_000, max: 120 }), require('./actions').router({ actions, requireAuth }));
++}
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
ddiff --git a/test/frame.test.js b/test/frame.test.js
iindex 0ffc664b..2aedb1b7 100644
---- a/test/frame.test.js
++++ b/test/frame.test.js
@@@ -14,7 +14,7 @@ assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
assert.equal(siteForHost('evil.example'), null);
assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');
let p = svc.payloadFor('openvibe.media');
--assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design'], 'cold start follows the base order');
++assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today'], 'cold start follows the base order');
// The launcher: every open site in one of the groups, and the products other services serve on their own domains
// right after their site (Pics and Download after Video, Homes after Rent).
assert.deepStrictEqual(p.groups.map(g => g.name), ['Streams & places', 'Games & laughs', 'News & answers', 'Make & build', 'Shop & swap', 'People & you', 'For developers'], 'the six neighbourhoods (plan 2026-10-28 §3.1), the building blocks last');
@@@ -26,9 +26,9 @@ assert.ok(at('pics') === at('media-hub') + 1 && at('homes') === at('rent') + 1,
assert.deepStrictEqual([p.nav[at('media-hub')].icon, p.nav[at('media-hub')].group, p.nav[at('homes')].group, p.nav[at('tools')].group], ['video', 'streams', 'shop', 'make'], 'Video draws the video icon (there is no media-hub icon)');
assert.deepStrictEqual(['actor', 'bot', 'run', 'zone'].map((id) => p.nav[at(id)].group), ['make', 'make', 'build', 'build'], 'agents and robots for people, compute and storage for developers');
// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).
--[['games', 'games'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make']]
++[['games', 'games'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers']]
.filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));
--assert.strictEqual(p.nav.length, 43); // Domains since contracts 0.226.0, Report since 0.227.0, Design since 0.228.0
++assert.strictEqual(p.nav.length, 44); // Domains since contracts 0.226.0, Report since 0.227.0, Design since 0.228.0, Today since 0.230.0
assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
ddiff --git a/test/me-actions.test.js b/test/me-actions.test.js
nnew file mode 100644
iindex 00000000..0cc6bbb1
---- /dev/null
++++ b/test/me-actions.test.js
@@@ -0,0 +1,135 @@
++'use strict';
++// The Action Center (server/actions, capability network.actions.read): GET /api/v1/me/actions merges the person's pending
++// confirmations with Actor's, Codes' and AI's items into five buckets (network.me-actions-result@1), with Network's own
++// token per service and the person's subject; a slow, missing or not-yet-deployed service is named in `sources`, never
++// fails the answer; an invalid item or one claiming another service is dropped; the answer is cached per person and
++// dropped when they act; approve/decline go to Network's confirmations or to Actor's decide route.
++// node --import ./test/helpers/pg-preload.mjs test/me-actions.test.js
++const assert = require('assert');
++const http = require('http');
++const crypto = require('crypto');
++const express = require('express');
++const { validate } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const actionsMod = require('../server/actions');
++const { signToken } = require('../server/auth/routes');
++
++const T0 = Date.parse('2026-10-28T15:00:00Z');
++const iso = (min) => new Date(T0 - min * 60000).toISOString();
++const item = (over) => ({
++ service: 'actor', id: 'tsk_1', kind: 'agent_task', title: 'Find three cheap flights', summary: 'Read 4 pages', bucket: 'running', state: 'running',
++ agent: 'deepseek-flash', cost_usd: 0.004, steps: 6, error: null, created_at: iso(10), updated_at: iso(2), next_run_at: null,
++ href: 'https://openvibe.actor/tasks/tsk_1', actions: ['open', 'cancel'], ...over,
++});
++
++(async () => {
++const db = getDb();
++const log = console.log; console.log = () => {};
++await db.prepare("INSERT INTO users (id, username, email, password_hash, email_verified) VALUES (51, 'acto', '[email protected]', 'x', 1)").run();
++
++// Fake Actor (two items, one invalid, one claiming to be Codes) and AI (slow); Codes is not deployed (404).
++const calls = [];
++let aiDelay = 0;
++const svc = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ 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/actions') return json(200, { items: [
++ item(),
++ 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' }),
++ item({ id: 'tsk_bad', href: 'http://openvibe.actor/tasks/x' }),
++ item({ id: 'tsk_spoof', service: 'codes' }),
++ item({ id: 'agt_1', bucket: 'scheduled', state: 'scheduled', next_run_at: new Date(T0 + 3600000).toISOString(), actions: ['open'] }),
++ item({ id: 'tsk_old', bucket: 'failed', state: 'failed', error: 'The page asked for a login', actions: ['open'], updated_at: iso(300) }),
++ ] });
++ if (u.pathname === '/ai/api/v1/actions') return setTimeout(() => json(200, { items: [item({ service: 'ai', id: 'run_1', kind: 'ai_run', bucket: 'done', state: 'succeeded', href: 'https://ai.openvibe.services/runs/run_1', actions: ['open'] })] }), aiDelay);
++ 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' });
++ json(404, { code: 'not_found' });
++ });
++});
++await new Promise((r) => svc.listen(0, '127.0.0.1', r));
++const base = `http://127.0.0.1:${svc.address().port}`;
++const decided = [];
++const fakeConfirmations = {
++ list: async () => ({
++ confirmations: [{ id: 'cnf_01JAB2C3D4E5F6G7H8J9K0MNPQ', owner: { type: 'user', id: 'x' }, requested_by: { type: 'agent', id: 'agt_x' }, capability: 'mail.send', summary: 'Send one email to the venue', state: 'pending', created_at: iso(1), expires_at: iso(-15) }],
++ agents: { agt_x: { id: 'agt_x', name: 'Trip planner' } },
++ }),
++ decide: async (_db, actor, id, action) => { decided.push([actor.subject, id, action]); return { confirmation: { id, capability: 'mail.send', summary: 'Send one email to the venue', state: action === 'approve' ? 'approved' : 'denied', created_at: iso(1) } }; },
++};
++let clock = T0;
++const actions = actionsMod.createActions({
++ db, urls: { actor: `${base}/actor`, codes: `${base}/codes`, ai: `${base}/ai` }, timeoutMs: 150, now: () => clock,
++ selfToken: (aud, caps) => `tok:${aud}:${caps.join(',')}`, confirmationsImpl: fakeConfirmations, log: { warn() {} },
++});
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), signToken);
++const user = await db.prepare('SELECT * FROM users WHERE id = 51').get();
++const token = signToken(user, keys.privateKey, config);
++const app = express();
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++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(() => ({})) }));
++
++try {
++ assert.strictEqual((await call('GET', '', false)).status, 401, 'a session is needed');
++
++ aiDelay = 400; // AI is slower than the 150 ms budget
++ 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));
++ assert.deepStrictEqual(doc.sources, { network: 'ok', actor: 'ok', codes: 'not_connected', ai: 'timeout' });
++ assert.strictEqual(doc.partial, true, 'a timeout makes the answer partial');
++ assert.deepStrictEqual(doc.counts, { running: 1, waiting: 2, scheduled: 1, done: 0, failed: 1 });
++ assert.deepStrictEqual(doc.buckets.waiting.map((x) => x.id), ['cnf_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'apr_1'], 'newest first');
++ const cnf = doc.buckets.waiting[0];
++ assert.deepStrictEqual([cnf.service, cnf.kind, cnf.title, cnf.summary, cnf.href, cnf.actions], ['network', 'approval', 'Send one email to the venue', 'Trip planner asks to use mail.send', 'https://openvibe.network/my#approvals', ['approve', 'decline', 'open']]);
++ const all = Object.values(doc.buckets).flat().map((x) => x.id);
++ assert.ok(!all.includes('tsk_bad'), 'an item that is not common.action-item@1 is dropped');
++ assert.ok(!all.includes('tsk_spoof'), 'an item claiming another service is dropped');
++ const ask = calls.find((c) => c.path === '/actor/api/v1/actions');
++ assert.deepStrictEqual([ask.auth, ask.subject && ask.subject.startsWith('usr_')], ['Bearer tok:openvibe.actor:actor.actions.read', true], 'Network\'s own token, the person\'s subject');
++ assert.strictEqual(calls.find((c) => c.path === '/ai/api/v1/actions').auth, 'Bearer tok:openvibe.ai:ai.actions.read');
++
++ // Cached for ten seconds: no second round of calls.
++ const n = calls.length;
++ await call('GET');
++ assert.strictEqual(calls.length, n, 'cached');
++ clock += actionsMod.CACHE_MS + 1;
++ aiDelay = 0;
++ r = await call('GET');
++ assert.ok(calls.length > n, 'read again after the cache');
++ assert.deepStrictEqual([r.body.sources.ai, r.body.partial, r.body.counts.done], ['ok', false, 1]);
++
++ // Acting: a Network confirmation through confirmations.decide, an Actor approval through Actor, others refused.
++ r = await call('POST', '/network/cnf_01JAB2C3D4E5F6G7H8J9K0MNPQ/approve');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual([r.body.item.state, r.body.item.bucket], ['approved', 'done']);
++ assert.strictEqual(decided[0][2], 'approve');
++ const before = calls.length;
++ await call('GET');
++ assert.ok(calls.length > before, 'acting drops the cached answer');
++ r = await call('POST', '/actor/apr_1/decline');
++ assert.strictEqual(r.status, 200);
++ const d = calls.find((c) => c.path === '/actor/api/v1/approvals/apr_1/decide');
++ assert.deepStrictEqual([d.method, d.auth, d.body.decision, d.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.actor:actor.approval.decide', 'decline', true]);
++ assert.strictEqual((await call('POST', '/actor/apr_404/approve')).status, 404);
++ assert.deepStrictEqual([(await call('POST', '/codes/tsk_1/approve')).body.code, (await call('POST', '/actor/tsk_1/cancel')).body.code], ['actions.unsupported', 'actions.unsupported']);
++ assert.strictEqual((await call('POST', '/actor/..%2Fx/approve')).status, 404, 'ids are plain');
++} finally {
++ server.close(); svc.close();
++ console.log = log;
++}
++console.log('me-actions: ok');
++process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/registry-exposure.test.js b/test/registry-exposure.test.js
iindex 2b79a177..ba26b7cb 100644
---- a/test/registry-exposure.test.js
++++ b/test/registry-exposure.test.js
@@@ -17,7 +17,7 @@ const { SITES } = require('../server/frame/sites');
const PINNED = {
network: 'live', live: 'live', tools: 'live', media: 'live', games: 'live', community: 'live', events: 'live',
billing: 'live', codes: 'live', blog: 'live', wiki: 'live', sites: 'retired', // Sites retired 2026-10-14 (contracts 0.181.0)
-- news: 'live', reviews: 'live', deals: 'live', coupons: 'live', trade: 'live', host: 'live', domains: 'live', report: 'live', design: 'live', // News, Reviews, Coupons, Trade live since contracts 0.125.0; Domains since 0.226.0, Report 0.227.0, Design 0.228.0
++ news: 'live', reviews: 'live', deals: 'live', coupons: 'live', trade: 'live', host: 'live', domains: 'live', report: 'live', design: 'live', today: 'live', // News, Reviews, Coupons, Trade live since contracts 0.125.0; Domains since 0.226.0, Report 0.227.0, Design 0.228.0
tips: 'live', vip: 'live', openre: 'live', search: 'live', sources: 'internal', chat: 'live', 'media-hub': 'live', space: 'live', // a home and redirects since the forum returned to Community (contracts 0.118.0)
// tips, vip: their own sites at openvibe.tips and openvibe.vip since contracts 0.200.0 (2026-10-17)
ai: 'live', // the public home and developer-app API at ai.openvibe.services since 2026-10-07 (contracts 0.110.0)
@@@ -121,7 +121,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
// v0.95.0 Services; Space and AI went live in v0.110.0; Food, Help and Work in v0.116.0; Quest, Rent and Watch in v0.117.0;
// Inventory in v0.122.0; none is retired
// News, Reviews, Coupons, Trade and OpenRestream in v0.125.0; Run in v0.141.0
-- assert.deepStrictEqual(st.exposure_summary, { live: 41, internal: 1, library: 4, repository: 1, placeholder: 0, retired: 1 }); // Sites retired (contracts 0.181.0); Tips and VIP live (0.200.0); Design live (0.228.0)
++ assert.deepStrictEqual(st.exposure_summary, { live: 42, internal: 1, library: 4, repository: 1, placeholder: 0, retired: 1 }); // Sites retired (contracts 0.181.0); Tips and VIP live (0.200.0); Design live (0.228.0)
// The page: no row for a service whose domain serves a placeholder shows a bare "Up".
const html = await fetch(base + '/status').then(x => x.text());
@@@ -137,7 +137,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
// 3. The Frame: the nav lists only sites whose domain serves the service; the rest are soon with a reason.
const open = SITES.filter(s => s.status === 'open').map(s => s.id);
-- assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design']); // domains: open since 0.226.0, report since 0.227.0, design since 0.228.0 (the 2026-10-28 rooms); tips, vip: open since 0.200.0; zone: a site since 0.173.0; play: a site since 0.172.0; run: a site since 0.141.0; events, ai and search: sites since 0.135.0; news, reviews, trade, coupons and stream (OpenRestream): open since 0.125.0; // by site position; bot: a site since openvibe-contracts 0.106.0; host: live since 0.107.0; space: live since 0.110.0 (the forum, 2026-10-07)
++ assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today']); // domains: open since 0.226.0, report since 0.227.0, design since 0.228.0 (the 2026-10-28 rooms); tips, vip: open since 0.200.0; zone: a site since 0.173.0; play: a site since 0.172.0; run: a site since 0.141.0; events, ai and search: sites since 0.135.0; news, reviews, trade, coupons and stream (OpenRestream): open since 0.125.0; // by site position; bot: a site since openvibe-contracts 0.106.0; host: live since 0.107.0; space: live since 0.110.0 (the forum, 2026-10-07)
for (const s of SITES) {
assert.ok(s.service && PINNED[s.service], `${s.id} names its service`);
assert.strictEqual(s.status === 'open', PINNED[s.service] === 'live', `${s.id} nav status follows exposure`);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 7dead87d..a4d3807b 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -125,6 +125,7 @@ const REVIEWED = {
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
++ 'server/actions/index.js fetch': [3, '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 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': [4, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030), OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) and OpenVibe.Play (OV_PLAY_INTERNAL_URL, loopback 5040) only; the paths are fixed, the subject is a checked usr_ id'],
'server/profile/avatar.js fetch': [3, 'Live and Media internal URLs from config; verifyImage only for https://openvibe.media/ addresses (normalizeAvatar, 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)'],