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)
Changes
@@@ -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@@@ -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 picturesserver {listen 80;@@@ -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"@@@ -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"nnew file mode 100644@@@ -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 };@@@ -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'] },@@@ -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.@@@ -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));@@@ -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.0assert.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');nnew file mode 100644@@@ -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); });@@@ -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.0tips: '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`);@@@ -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)'],
@@ -280,6 +280,20 @@ curl -X POST http://127.0.0.1:4000/internal/notifications/push \
@@ -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"}}'
-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
### Notifications from Events
`POST /internal/events` (`server/notifications/events-consumer.js`) turns OpenVibe.Events deliveries
`POST /internal/events` (`server/notifications/events-consumer.js`) turns OpenVibe.Events deliveries
@@ -81,28 +81,6 @@ server {
@@ -81,28 +81,6 @@ server {
return 302 https://openvibe.deals/search?q=shoes;
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
# openvibe.vision → AI, whose models read pictures
server {
server {
listen 80;
listen 80;
@@ -20,9 +20,9 @@
@@ -20,9 +20,9 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -1320,9 +1320,9 @@
@@ -1320,9 +1320,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1364,9 +1364,9 @@
@@ -1364,9 +1364,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -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 };
@@ -122,6 +122,8 @@ async function seedDb(db, { log = console } = {}) {
@@ -122,6 +122,8 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'help', name: 'OpenVibe.Help', redirect_uris: ['https://openvibe.help/auth/callback'] },
{ 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.
// 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'] },
{ 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'] },
{ 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).
// 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'] },
{ client_id: 'quest', name: 'OpenVibe.Quest', redirect_uris: ['https://openvibe.quest/auth/callback'] },
@@ -27,7 +27,7 @@ const DEFAULT_GRANTS = [
@@ -27,7 +27,7 @@ const DEFAULT_GRANTS = [
['quest', 'identity.subject.resolve', SELF_AUDIENCE, []],
['quest', 'identity.subject.resolve', SELF_AUDIENCE, []],
['quest', 'events.subscription.manage', 'openvibe.events', []],
['quest', 'events.subscription.manage', 'openvibe.events', []],
// They subscribe to network.account.export_requested and network.account.deleted at boot (ADR-033).
// 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,
// 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).
// contracts 0.128.0). Inventory publishes already (below).
...['work', 'rent', 'help', 'quest'].map((svc) => [svc, 'events.event.publish', 'openvibe.events', []]),
...['work', 'rent', 'help', 'quest'].map((svc) => [svc, 'events.event.publish', 'openvibe.events', []]),
@@ -446,6 +446,11 @@ const DEFAULT_GRANTS = [
@@ -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
// 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.
// in X-OV-Subject, so the paid steps count against that person's daily allowance.
['actor', 'search.query.delegate', 'openvibe.search', []],
['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,
// 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
// 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.
// name; no namespace, so Chat's module namespaces stay the chat.* ones). Chat already resolves subjects.
@@ -622,6 +622,16 @@ require('./identity/grants-admin').start(db);
@@ -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).
// Pending confirmations past expires_at are recorded expired every minute (reads report them expired before that).
require('./developer/confirmations').setNotifier(notificationService);
require('./developer/confirmations').setNotifier(notificationService);
require('./developer/confirmations').start(db);
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)
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
@@ -14,7 +14,7 @@ assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
@@ -14,7 +14,7 @@ assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
assert.equal(siteForHost('evil.example'), null);
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)');
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');
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
// 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).
// 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');
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,
@@ -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([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');
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 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}`));
.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.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 === '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');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -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); });
@@ -17,7 +17,7 @@ const { SITES } = require('../server/frame/sites');
@@ -17,7 +17,7 @@ const { SITES } = require('../server/frame/sites');
const PINNED = {
const PINNED = {
network: 'live', live: 'live', tools: 'live', media: 'live', games: 'live', community: 'live', events: 'live',
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)
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: '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)
// 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)
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
@@ -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;
// 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
// Inventory in v0.122.0; none is retired
// News, Reviews, Coupons, Trade and OpenRestream in v0.125.0; Run in v0.141.0
// 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".
// 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());
const html = await fetch(base + '/status').then(x => x.text());
@@ -137,7 +137,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
@@ -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.
// 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);
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) {
for (const s of SITES) {
assert.ok(s.service && PINNED[s.service], `${s.id} names its service`);
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`);
assert.strictEqual(s.status === 'open', PINNED[s.service] === 'live', `${s.id} nav status follows exposure`);
@@ -125,6 +125,7 @@ const REVIEWED = {
@@ -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/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/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/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/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/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)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],