openvibers/openvibe.network Commits

Machine access requests, grants and the agent directory (Contracts 0.409.0) (#849)

OP OpenVibers committed 1 days ago · 47083d64b0 · 18 files, +1007 −21

* Machine access requests, grants and the agent directory (Contracts 0.409.0)

* Merge remote-tracking branch 'origin/main' into machine-access-agents-network

* Keep a service agent's sender inside the person its token acts for

* Consume Contracts 0.409.0: Compare joins Read, and update the test ratchets

* Merge remote-tracking branch 'origin/main' into machine-access-agents-network

Changes

ddiff --git a/migrations/0077_link_access_agents.sql b/migrations/0077_link_access_agents.sql
nnew file mode 100644
iindex 00000000..09ccd89e
---- /dev/null
++++ b/migrations/0077_link_access_agents.sql
@@@ -0,0 +1,99 @@
++-- phase: expand
++-- Machine access and the account's agent directory. All switches default off.
++CREATE TABLE IF NOT EXISTS link_access_settings (
++ principal_id text COLLATE "C" PRIMARY KEY REFERENCES platform_node_principals(id) ON DELETE CASCADE,
++ others_may_ask bigint NOT NULL DEFAULT 0 CHECK (others_may_ask IN (0, 1)),
++ auto_agent_send bigint NOT NULL DEFAULT 0 CHECK (auto_agent_send IN (0, 1)),
++ agents_talk bigint NOT NULL DEFAULT 0 CHECK (agents_talk IN (0, 1)),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
++);
++
++CREATE TABLE IF NOT EXISTS link_access_requests (
++ id text COLLATE "C" PRIMARY KEY,
++ from_principal text COLLATE "C" NOT NULL REFERENCES platform_node_principals(id),
++ to_principal text COLLATE "C" NOT NULL REFERENCES platform_node_principals(id),
++ from_owner text COLLATE "C" NOT NULL,
++ to_owner text COLLATE "C" NOT NULL,
++ from_agent text COLLATE "C",
++ scopes text COLLATE "C" NOT NULL,
++ reason text COLLATE "C",
++ duration_seconds bigint,
++ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','denied','expired')),
++ asked_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ pending_until text COLLATE "C" NOT NULL,
++ answered_at text COLLATE "C",
++ CHECK (from_principal <> to_principal)
++);
++CREATE INDEX IF NOT EXISTS idx_link_access_requests_to ON link_access_requests(to_owner, status, asked_at);
++CREATE INDEX IF NOT EXISTS idx_link_access_requests_daily ON link_access_requests(from_owner, to_owner, asked_at);
++
++CREATE TABLE IF NOT EXISTS link_access_grants (
++ id text COLLATE "C" PRIMARY KEY,
++ request_id text COLLATE "C" REFERENCES link_access_requests(id),
++ from_principal text COLLATE "C" NOT NULL REFERENCES platform_node_principals(id),
++ to_principal text COLLATE "C" NOT NULL REFERENCES platform_node_principals(id),
++ from_owner text COLLATE "C" NOT NULL,
++ to_owner text COLLATE "C" NOT NULL,
++ scopes text COLLATE "C" NOT NULL,
++ approved_by text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ expires_at text COLLATE "C",
++ revoked_at text COLLATE "C"
++);
++CREATE INDEX IF NOT EXISTS idx_link_access_grants_from ON link_access_grants(from_principal, revoked_at);
++CREATE INDEX IF NOT EXISTS idx_link_access_grants_to ON link_access_grants(to_principal, revoked_at);
++
++CREATE TABLE IF NOT EXISTS link_access_audit (
++ id text COLLATE "C" PRIMARY KEY,
++ grant_id text COLLATE "C" NOT NULL REFERENCES link_access_grants(id),
++ from_owner text COLLATE "C" NOT NULL,
++ to_owner text COLLATE "C" NOT NULL,
++ scope text COLLATE "C" NOT NULL,
++ action text COLLATE "C" NOT NULL,
++ at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
++);
++CREATE INDEX IF NOT EXISTS idx_link_access_audit_owners ON link_access_audit(from_owner, to_owner, at);
++
++CREATE TABLE IF NOT EXISTS agent_directory (
++ id text COLLATE "C" PRIMARY KEY,
++ owner_subject text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('actor','codes','link-local','chat-bot','service')),
++ name text COLLATE "C" NOT NULL,
++ service text COLLATE "C",
++ machine_id text COLLATE "C" REFERENCES platform_node_principals(id),
++ endpoint text COLLATE "C",
++ skills text COLLATE "C" NOT NULL DEFAULT '[]',
++ status text COLLATE "C" NOT NULL DEFAULT 'offline' CHECK (status IN ('online','busy','offline')),
++ allow_others bigint NOT NULL DEFAULT 0 CHECK (allow_others IN (0, 1)),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
++);
++CREATE INDEX IF NOT EXISTS idx_agent_directory_owner ON agent_directory(owner_subject, updated_at);
++
++CREATE TABLE IF NOT EXISTS agent_threads (
++ id text COLLATE "C" PRIMARY KEY,
++ from_agent text COLLATE "C" NOT NULL REFERENCES agent_directory(id),
++ to_agent text COLLATE "C" NOT NULL REFERENCES agent_directory(id),
++ from_owner text COLLATE "C" NOT NULL,
++ to_owner text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ last_answer_at text COLLATE "C",
++ closes_at text COLLATE "C" NOT NULL,
++ stopped_at text COLLATE "C"
++);
++CREATE INDEX IF NOT EXISTS idx_agent_threads_owners ON agent_threads(from_owner, to_owner, created_at);
++
++CREATE TABLE IF NOT EXISTS agent_tasks (
++ id text COLLATE "C" PRIMARY KEY,
++ thread_id text COLLATE "C" NOT NULL REFERENCES agent_threads(id),
++ from_agent text COLLATE "C" NOT NULL REFERENCES agent_directory(id),
++ to_agent text COLLATE "C" NOT NULL REFERENCES agent_directory(id),
++ on_behalf_of text COLLATE "C" NOT NULL,
++ message text COLLATE "C" NOT NULL,
++ skill_id text COLLATE "C",
++ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','completed','failed','cancelled')),
++ result text COLLATE "C",
++ remote_id text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso(),
++ updated_at text COLLATE "C" NOT NULL DEFAULT ov_now_iso()
++);
++CREATE INDEX IF NOT EXISTS idx_agent_tasks_rate ON agent_tasks(from_agent, to_agent, created_at);
ddiff --git a/package-lock.json b/package-lock.json
iindex e8aad383..694c49ae 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -22,7 +22,7 @@
"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.407.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"otpauth": "9.6.0",
@@@ -2679,9 +2679,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.407.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.407.0",
-- "integrity": "sha512-i+brTUApefkumQu1Ra4GFkZqwd2+mWBIEW8Ra55Eid5Bn6Q29cVNMl57EKI9uzlOauzc6s6ut5r55NWepRoMSQ==",
++ "version": "0.409.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",
++ "integrity": "sha512-Yh8XcOEKIrYG7WV8ack03hmobZwMyZIeOogIrCeIWvuHKpvjYYL+UcOj7DrNalxC5fp0EnhVwRj89DTFm/AH2g==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 8bdce351..589eb803 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"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.407.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.409.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"otpauth": "9.6.0",
ddiff --git a/server/frame/sites.js b/server/frame/sites.js
iindex 36742b13..352e4058 100644
---- a/server/frame/sites.js
++++ b/server/frame/sites.js
@@@ -63,7 +63,7 @@ const GROUPS = [
{ id: 'watch', name: 'Watch & stream', sites: ['live', 'media-hub', 'pics', 'download', 'stream', 'cam', 'tours'] },
{ id: 'talk', name: 'Talk & belong', sites: ['community', 'chat', 'tips', 'vip', 'mail', 'help', 'network'] },
{ id: 'play', name: 'Play', sites: ['games', 'quest', 'surf', 'fun', 'meme', 'casino', 'inventory'] },
-- { id: 'read', name: 'Read & ask', sites: ['news', 'today', 'report', 'search', 'wiki', 'blog', 'vision'] },
++ { id: 'read', name: 'Read & ask', sites: ['news', 'today', 'report', 'search', 'wiki', 'blog', 'vision', 'compare'] },
{ id: 'shop', name: 'Shop & money', sites: ['deals', 'coupons', 'shoes', 'reviews', 'trade', 'rent', 'work'] },
{ id: 'tools', name: 'Free tools', sites: ['tools', 'design', 'domains', 'host', 'image', 'lock', 'food'] },
// Actor is the engine behind every agent (Codes' Auto, Vibe, the Dock call it): a developer service, not a second
@@@ -79,7 +79,7 @@ const MENU_LINE = {
download: 'Your files', cam: 'Your camera, in private', tours: 'Cities, routes, stays',
community: 'Posts and pastes', chat: 'Chat with anyone', tips: 'Support streamers', vip: 'Back your creators', mail: 'Masks and inboxes', help: 'Help nearby', network: 'Your account',
games: 'Play in your browser', quest: 'Earn badges', surf: 'Surf maps and the web', fun: 'Something to do', meme: 'Memes with receipts', casino: 'Odds shown, chips free', inventory: 'Your items',
-- news: 'Sourced, not spun', today: 'Your schedule', report: 'Your numbers, honest', search: 'Search everything', wiki: 'Facts with sources', blog: 'Write long-form', vision: 'Ask a picture',
++ news: 'Sourced, not spun', today: 'Your schedule', report: 'Your numbers, honest', search: 'Search everything', wiki: 'Facts with sources', blog: 'Write long-form', vision: 'Ask a picture', compare: 'Compare anything',
deals: 'Deals people share', coupons: 'Codes that work', shoes: 'Every shoe, every price', reviews: 'Honest reviews', trade: 'Watch the markets', rent: 'Places to rent', work: 'Find a job',
tools: 'Every online tool', design: 'Try themes live', domains: 'Names and prices', host: 'Compare hosting', image: 'Edit photos in your browser', lock: 'Files only you can open', food: 'Food near you',
space: 'Host a site or app', codes: 'Code with any agent', bot: 'Drive a robot', actor: 'The agent engine', computer: 'Your computer, your way', connect: 'Share a port with Link', directory: 'Listings with sources',
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex a443e568..ef8aaad7 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -408,6 +408,14 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
// network.link.revoked@1 in this same transaction, so a service holding a machine's session stops it at once.
const nodePrincipals = require('../registry/link-principals');
count('nodes_revoked', await nodePrincipals.revokeOwnedBy(db, subjects, { actor: { type: 'user', id: subject }, now }));
++ // An agent thread can contain another person's message too. Remove the shared thread and its open tasks
++ // when either participant deletes their account; no agent or machine can continue it afterwards.
++ for (const sid of subjects) {
++ await db.prepare('DELETE FROM agent_tasks WHERE thread_id IN (SELECT id FROM agent_threads WHERE from_owner = ? OR to_owner = ?)').run(sid, sid);
++ await db.prepare('DELETE FROM agent_threads WHERE from_owner = ? OR to_owner = ?').run(sid, sid);
++ count('agents_deleted', (await db.prepare('DELETE FROM agent_directory WHERE owner_subject = ?').run(sid)).changes);
++ await db.prepare('DELETE FROM link_access_audit WHERE from_owner = ? OR to_owner = ?').run(sid, sid);
++ }
// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.
for (const id of userIds) {
const bal = (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 157149bd..1a8cbeb9 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -31,6 +31,12 @@ const CHAT_NAMESPACES = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_setti
// Initial grants: what each service does against Network today.
const DEFAULT_GRANTS = [
++ // Agents register only their own person's records; Bot reports machine task answers and grant use.
++ ['actor', 'network.agents.register', SELF_AUDIENCE, []],
++ ['codes', 'network.agents.register', SELF_AUDIENCE, []],
++ ['bot', 'network.agents.relay', SELF_AUDIENCE, []],
++ ['network', 'bot.machine.relay', 'openvibe.bot', []],
++ ['network', 'actor.a2a.task', 'openvibe.actor', []],
// Mail relay checks one address at a time before forwarding; older Contracts pins omit this new capability.
...(capabilities.get(EMAIL_READ_CAPABILITY) ? [['mail', EMAIL_READ_CAPABILITY, SELF_AUDIENCE, []]] : []),
['mail', 'identity.trust.read', SELF_AUDIENCE, []],
ddiff --git a/server/index.js b/server/index.js
iindex 04bf316a..58f9beff 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -641,6 +641,20 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/api/v1/link/self/send', rateLimit({ windowMs: 60_000, max: 300 }), sendRelay.self);
app.use('/api/v1/me/nodes/send-keys', rateLimit({ windowMs: 60_000, max: 60 }), sendRelay.keys);
app.use('/api/v1/me/nodes/send', rateLimit({ windowMs: 60_000, max: 300 }), sendRelay.web);
++ const access = require('./link/access').routers({ requireAuth,
++ nodeGuard: require('./identity/principals').guard('network.link.access.request'),
++ relayGuard: require('./identity/principals').guard('network.agents.relay') });
++ app.use('/api/v1/link/self', rateLimit({ windowMs: 60_000, max: 60 }), access.self);
++ app.use('/api/v1/me/nodes', rateLimit({ windowMs: 60_000, max: 60 }), access.user);
++ app.use('/internal/link-access', access.internal);
++ const agents = require('./link/agents').routers({ requireAuth,
++ registerGuard: require('./identity/principals').guardAny(['network.agents.register', 'network.link.self.manage']),
++ resultGuard: require('./identity/principals').guardAny(['network.agents.relay', 'network.agents.register']),
++ bot: require('./registry/machines').createBotClient({ baseUrl: config.services.bot.internalUrl, privateKey, issuer: config.jwt.issuer }),
++ config, privateKey, issuer: config.jwt.issuer });
++ app.use('/api/v1/agents', rateLimit({ windowMs: 60_000, max: 600 }), agents.results);
++ app.use('/api/v1/agents', rateLimit({ windowMs: 60_000, max: 600 }), agents.relay);
++ app.use('/api/v1/me', rateLimit({ windowMs: 60_000, max: 120 }), agents.me);
}
// The machines hub (plan L34, step M2): a person's machines on openvibe.link/machines. Bot stays the registry
// underneath; Network fronts it and calls it as the person, so Bot checks the machine is that person's own.
ddiff --git a/server/link/access.js b/server/link/access.js
nnew file mode 100644
iindex 00000000..7f43997a
---- /dev/null
++++ b/server/link/access.js
@@@ -0,0 +1,346 @@
++'use strict';
++
++// An asking machine and a receiving machine are always resolved from live Network principals.
++// Bot checks the short-lived signed token and this row again before delivering each frame.
++const crypto = require('node:crypto');
++const jwt = require('jsonwebtoken');
++const express = require('express');
++const { ids, http, validate } = require('openvibe-contracts');
++const { ensureUserSubject } = require('../identity/subjects');
++const eventRelay = require('../developer/event-relay');
++
++const SCOPES = new Set(['agent', 'send', 'screen', 'desk', 'computer', 'worker']);
++const PRINCIPAL = /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const ONE_HOUR = 3600;
++const MAX_SECONDS = 30 * 24 * ONE_HOUR;
++const DAY_MS = 24 * ONE_HOUR * 1000;
++const id = (prefix) => `${prefix}_${crypto.randomBytes(16).toString('hex')}`;
++const iso = (ms) => new Date(ms).toISOString();
++const fail = (status, code, detail) => { throw Object.assign(new Error(detail), { status, code }); };
++const array = (raw) => { try { return JSON.parse(raw); } catch { return []; } };
++
++function scopesOf(value) {
++ if (!Array.isArray(value) || !value.length || value.length > SCOPES.size ||
++ value.some((s) => !SCOPES.has(s)) || new Set(value).size !== value.length) {
++ fail(422, 'link_access.invalid_scopes', 'choose one or more distinct access scopes');
++ }
++ return value;
++}
++
++function durationOf(value, scopes, own) {
++ if (value === null) {
++ if (!own || scopes.some((s) => s !== 'agent' && s !== 'send')) {
++ fail(422, 'link_access.duration', 'until revoked is for your own machines and agent or send only');
++ }
++ return null;
++ }
++ const n = value === undefined ? ONE_HOUR : Number(value);
++ if (!Number.isInteger(n) || n < 60 || n > MAX_SECONDS) fail(422, 'link_access.duration', 'duration must be 60 seconds to 30 days');
++ return n;
++}
++
++async function machine(db, principal) {
++ if (!PRINCIPAL.test(String(principal))) return null;
++ return db.prepare("SELECT id, name, owner_subject FROM platform_node_principals WHERE id = ? AND owner_kind = 'user' AND status = 'active'").get(principal);
++}
++
++async function target(db, input) {
++ const value = String(input || '').trim();
++ if (PRINCIPAL.test(value)) return machine(db, value);
++ // The handle form reveals nothing unless the request is accepted by the owner policy below.
++ const named = /^@([a-z0-9_]{2,32})\/(.{1,80})$/i.exec(value);
++ if (!named) return null;
++ const owner = await db.prepare('SELECT subject_id FROM users WHERE lower(username) = lower(?)').get(named[1]);
++ if (!owner) return null;
++ return db.prepare("SELECT id, name, owner_subject FROM platform_node_principals WHERE owner_subject = ? AND lower(name) = lower(?) AND owner_kind = 'user' AND status = 'active' ORDER BY id LIMIT 1").get(owner.subject_id, named[2]);
++}
++
++async function selfNode(db, req) {
++ const sub = String(req.principal?.sub || '');
++ if (!sub.startsWith('link:')) fail(403, 'link_access.machine_required', 'a paired machine token is required');
++ const p = await machine(db, sub.slice(5));
++ if (!p) fail(401, 'link_access.machine_unavailable', 'the machine is no longer paired');
++ return p;
++}
++
++async function owner(db, req) {
++ if (!req.user) fail(401, 'link_access.sign_in', 'sign in to manage machine access');
++ return ensureUserSubject(db, req.user);
++}
++
++async function settings(db, principal) {
++ const row = await db.prepare('SELECT others_may_ask, auto_agent_send, agents_talk FROM link_access_settings WHERE principal_id = ?').get(principal);
++ return { others_may_ask: !!row?.others_may_ask, auto_agent_send: !!row?.auto_agent_send, agents_talk: !!row?.agents_talk };
++}
++
++async function putSettings(db, principal, who, body) {
++ const p = await machine(db, principal);
++ if (!p || p.owner_subject !== who) fail(404, 'link_access.machine_not_found', 'no such machine of yours');
++ const keys = ['others_may_ask', 'auto_agent_send', 'agents_talk'];
++ if (!body || typeof body !== 'object' || Object.keys(body).some((key) => !keys.includes(key)) ||
++ keys.some((key) => body[key] !== undefined && typeof body[key] !== 'boolean')) {
++ fail(422, 'link_access.invalid_settings', 'settings must be boolean owner switches');
++ }
++ const old = await settings(db, principal);
++ const next = Object.fromEntries(keys.map((key) => [key, body[key] === undefined ? old[key] : body[key]]));
++ await db.prepare(`INSERT INTO link_access_settings (principal_id, others_may_ask, auto_agent_send, agents_talk, updated_at)
++ VALUES (?, ?, ?, ?, ?) ON CONFLICT (principal_id) DO UPDATE SET others_may_ask = excluded.others_may_ask,
++ auto_agent_send = excluded.auto_agent_send, agents_talk = excluded.agents_talk, updated_at = excluded.updated_at`)
++ .run(principal, +next.others_may_ask, +next.auto_agent_send, +next.agents_talk, iso(Date.now()));
++ return next;
++}
++
++function envelope(type, payload, ownerId, subjectId, at) {
++ const env = { event_id: ids.newId('event', Date.parse(at)), event_type: type, version: 1, source: 'network',
++ actor: { type: 'user', id: ownerId }, timestamp: at, visibility: 'internal',
++ subject: { type: 'node_principal', id: subjectId }, payload };
++ const ev = validate('events.event-envelope@1', env), pv = validate(`${type}@1`, payload);
++ if (!ev.valid || !pv.valid) throw new Error(`link access event invalid: ${JSON.stringify((ev.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
++ return env;
++}
++
++async function emit(db, type, payload, who, nodeId, at) {
++ await eventRelay.writerFor(db).enqueue(db, envelope(type, payload, who, nodeId, at));
++}
++
++function requestView(r) {
++ return { id: r.id, from_principal: r.from_principal, to_principal: r.to_principal,
++ from_owner: r.from_owner, to_owner: r.to_owner, from_agent: r.from_agent || null,
++ scopes: array(r.scopes), reason: r.reason || null, duration_seconds: r.duration_seconds == null ? null : Number(r.duration_seconds),
++ status: r.status, asked_at: r.asked_at, pending_until: r.pending_until, answered_at: r.answered_at || null };
++}
++function grantView(g) {
++ return { id: g.id, request_id: g.request_id || null, from_principal: g.from_principal, to_principal: g.to_principal,
++ from_owner: g.from_owner, to_owner: g.to_owner, scopes: array(g.scopes), approved_by: g.approved_by,
++ created_at: g.created_at, expires_at: g.expires_at || null, revoked_at: g.revoked_at || null };
++}
++
++async function ask(db, from, body, now = Date.now()) {
++ const to = await target(db, body?.to_principal || body?.target);
++ if (!to || to.id === from.id) fail(404, 'link_access.target_unavailable', 'that machine is unavailable');
++ const scopes = scopesOf(body?.scopes), sameOwner = from.owner_subject === to.owner_subject;
++ const duration = durationOf(body?.duration_seconds, scopes, sameOwner);
++ const reason = body?.reason == null ? null : String(body.reason).trim();
++ if (reason && (reason.length > 500 || /[\u0000-\u001f\u007f]/.test(reason))) fail(422, 'link_access.reason', 'reason must be plain text up to 500 characters');
++ if (!sameOwner) {
++ const switches = await settings(db, to.id);
++ if (!switches.others_may_ask) fail(403, 'link_access.others_off', 'the owner is not accepting requests');
++ // The owner's follow graph (server/identity/follows.js): its one target kind today is a Live channel, named
++ // by the target person's subject, so an active channel follow of the asker is the person's follow.
++ const followed = await db.prepare("SELECT 1 FROM user_follows WHERE follower_subject = ? AND target_type = 'channel' AND target_id = ? AND active = 1")
++ .get(to.owner_subject, from.owner_subject);
++ if (!followed) fail(403, 'link_access.not_followed', 'the receiving owner has not added the asker');
++ }
++ const agent = body?.from_agent == null ? null : String(body.from_agent);
++ if (agent) {
++ const row = await db.prepare('SELECT owner_subject, machine_id FROM agent_directory WHERE id = ?').get(agent);
++ if (!row || row.owner_subject !== from.owner_subject || row.machine_id !== from.id) fail(403, 'link_access.agent_not_owned', 'that agent is not on the asking machine');
++ }
++ const request = await db.tx(async () => {
++ if (!sameOwner) {
++ // Serialize the count for one receiving machine, so concurrent asks cannot pass the fifth slot together.
++ await db.prepare('SELECT id FROM platform_node_principals WHERE id = ? FOR UPDATE').get(to.id);
++ const since = iso(now - DAY_MS);
++ const refused = await db.prepare("SELECT 1 FROM link_access_requests WHERE from_owner = ? AND to_owner = ? AND status = 'denied' AND answered_at > ? LIMIT 1")
++ .get(from.owner_subject, to.owner_subject, since);
++ if (refused) fail(429, 'link_access.refused_cooldown', 'a refusal starts a one-day wait');
++ const count = await db.prepare('SELECT COUNT(*) AS n FROM link_access_requests WHERE from_owner = ? AND to_owner = ? AND asked_at > ?')
++ .get(from.owner_subject, to.owner_subject, since);
++ if (Number(count.n) >= 5) fail(429, 'link_access.daily_limit', 'five requests per day is the limit');
++ }
++ const at = iso(now), reqId = id('req'), pendingUntil = iso(now + DAY_MS);
++ await db.prepare(`INSERT INTO link_access_requests (id, from_principal, to_principal, from_owner, to_owner, from_agent, scopes, reason,
++ duration_seconds, asked_at, pending_until) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(reqId, from.id, to.id, from.owner_subject, to.owner_subject, agent, JSON.stringify(scopes), reason, duration, at, pendingUntil);
++ await emit(db, 'network.link.access.requested', { request_id: reqId, from_principal: from.id, to_principal: to.id, at }, from.owner_subject, to.id, at);
++ return requestView(await db.prepare('SELECT * FROM link_access_requests WHERE id = ?').get(reqId));
++ });
++ if (sameOwner && scopes.every((s) => s === 'agent' || s === 'send') && (await settings(db, to.id)).auto_agent_send) {
++ return (await answer(db, request.id, to.owner_subject, { approve: true }, now)).request;
++ }
++ return request;
++}
++
++async function answer(db, requestId, who, body, now = Date.now()) {
++ return db.tx(async () => {
++ const r = await db.prepare('SELECT * FROM link_access_requests WHERE id = ? FOR UPDATE').get(requestId);
++ if (!r || r.to_owner !== who) fail(404, 'link_access.request_not_found', 'no such request of yours');
++ if (r.status !== 'pending') fail(409, 'link_access.already_answered', 'this request was already settled');
++ const at = iso(now);
++ if (Date.parse(r.pending_until) <= now) {
++ await db.prepare("UPDATE link_access_requests SET status = 'expired', answered_at = ? WHERE id = ?").run(at, r.id);
++ await emit(db, 'network.link.access.answered', { request_id: r.id, status: 'expired', at }, who, r.to_principal, at);
++ return { request: requestView(await db.prepare('SELECT * FROM link_access_requests WHERE id = ?').get(r.id)), grant: null, expired: true };
++ }
++ const approved = body?.approve === true;
++ if (body?.approve !== true && body?.approve !== false) fail(422, 'link_access.answer', 'approve must be true or false');
++ let grant = null;
++ if (approved) {
++ const wanted = array(r.scopes), chosen = body.scopes === undefined ? wanted : scopesOf(body.scopes);
++ if (chosen.some((s) => !wanted.includes(s))) fail(422, 'link_access.widened_scope', 'an answer may only narrow scopes');
++ const seconds = body.duration_seconds === undefined ? (r.duration_seconds == null ? null : Number(r.duration_seconds)) :
++ durationOf(body.duration_seconds, chosen, r.from_owner === r.to_owner);
++ if (r.duration_seconds != null && (seconds == null || seconds > Number(r.duration_seconds))) {
++ fail(422, 'link_access.widened_time', 'an answer may only shorten time');
++ }
++ const a = await machine(db, r.from_principal), b = await machine(db, r.to_principal);
++ if (!a || !b || a.owner_subject !== r.from_owner || b.owner_subject !== r.to_owner) fail(409, 'link_access.machine_unavailable', 'a machine changed since it asked');
++ const grantId = id('grt'), expires = seconds == null ? null : iso(now + seconds * 1000);
++ await db.prepare(`INSERT INTO link_access_grants (id, request_id, from_principal, to_principal, from_owner, to_owner,
++ scopes, approved_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(grantId, r.id, r.from_principal, r.to_principal, r.from_owner, r.to_owner, JSON.stringify(chosen), who, at, expires);
++ grant = grantView(await db.prepare('SELECT * FROM link_access_grants WHERE id = ?').get(grantId));
++ }
++ const status = approved ? 'approved' : 'denied';
++ await db.prepare('UPDATE link_access_requests SET status = ?, answered_at = ? WHERE id = ?').run(status, at, r.id);
++ await emit(db, 'network.link.access.answered', { request_id: r.id, status, ...(grant ? { grant_id: grant.id } : {}), at }, who, r.to_principal, at);
++ return { request: requestView(await db.prepare('SELECT * FROM link_access_requests WHERE id = ?').get(r.id)), grant };
++ });
++}
++
++async function expirePending(db, now = Date.now()) {
++ const rows = await db.prepare("SELECT * FROM link_access_requests WHERE status = 'pending' AND pending_until <= ? ORDER BY pending_until LIMIT 100").all(iso(now));
++ for (const row of rows) await db.tx(async () => {
++ const at = iso(now);
++ const changed = await db.prepare("UPDATE link_access_requests SET status = 'expired', answered_at = ? WHERE id = ? AND status = 'pending'").run(at, row.id);
++ if (changed.changes) await emit(db, 'network.link.access.answered', { request_id: row.id, status: 'expired', at }, row.to_owner, row.to_principal, at);
++ });
++ return rows.length;
++}
++
++async function liveGrant(db, grantId, now = Date.now()) {
++ const g = await db.prepare('SELECT * FROM link_access_grants WHERE id = ?').get(grantId);
++ if (!g || g.revoked_at || (g.expires_at && Date.parse(g.expires_at) <= now)) return null;
++ const a = await machine(db, g.from_principal), b = await machine(db, g.to_principal);
++ if (!a || !b || a.owner_subject !== g.from_owner || b.owner_subject !== g.to_owner) return null;
++ return g;
++}
++
++async function grantToken(db, grantId, from, privateKey, issuer, now = Date.now()) {
++ const g = await liveGrant(db, grantId, now);
++ if (!g || g.from_principal !== from.id) fail(404, 'link_access.grant_not_found', 'no live grant for this machine');
++ const iat = Math.floor(now / 1000), exp = Math.min(iat + 300, g.expires_at ? Math.floor(Date.parse(g.expires_at) / 1000) : iat + 300);
++ if (exp <= iat) fail(410, 'link_access.expired', 'the grant expired');
++ const claims = { iss: issuer, aud: g.to_principal, sub: g.from_principal, jti: id('tok'), grant_id: g.id, scopes: array(g.scopes), iat, exp };
++ const checked = validate('link.access-grant-token@1', claims);
++ if (!checked.valid) throw new Error(`grant claims invalid: ${JSON.stringify(checked.errors).slice(0, 300)}`);
++ const algorithm = String(privateKey).includes('BEGIN') ? 'RS256' : 'HS256';
++ return { grant: grantView(g), grant_token: jwt.sign(claims, privateKey, { algorithm, header: { kid: 'ov-network-1', typ: 'JWT' } }), expires_at: iso(exp * 1000) };
++}
++
++async function revoke(db, grantId, who, now = Date.now()) {
++ return db.tx(async () => {
++ const g = await db.prepare('SELECT * FROM link_access_grants WHERE id = ? FOR UPDATE').get(grantId);
++ if (!g || (g.from_owner !== who && g.to_owner !== who)) fail(404, 'link_access.grant_not_found', 'no such grant of yours');
++ if (!g.revoked_at) {
++ const at = iso(now);
++ await db.prepare('UPDATE link_access_grants SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(at, g.id);
++ await emit(db, 'network.link.access.revoked', { grant_id: g.id, from_principal: g.from_principal, to_principal: g.to_principal, at }, who, g.to_principal, at);
++ }
++ return grantView(await db.prepare('SELECT * FROM link_access_grants WHERE id = ?').get(g.id));
++ });
++}
++
++// Called inside the existing unpair and account-deletion transactions.
++async function revokeForPrincipal(db, principal, ownerId, now = Date.now()) {
++ const rows = await db.prepare('SELECT * FROM link_access_grants WHERE (from_principal = ? OR to_principal = ?) AND revoked_at IS NULL').all(principal, principal);
++ const at = iso(now);
++ for (const row of rows) {
++ await db.prepare('UPDATE link_access_grants SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(at, row.id);
++ await emit(db, 'network.link.access.revoked', { grant_id: row.id, from_principal: row.from_principal,
++ to_principal: row.to_principal, at }, ownerId, row.to_principal, at);
++ }
++ await db.prepare("UPDATE agent_directory SET status = 'offline' WHERE machine_id = ?").run(principal);
++ return rows.length;
++}
++
++async function useGrant(db, grantId, from, to, scope, action, now = Date.now()) {
++ const g = await liveGrant(db, grantId, now);
++ if (!g || g.from_principal !== from || g.to_principal !== to || !array(g.scopes).includes(scope)) {
++ fail(403, 'link_access.grant_denied', 'no live grant for this frame and scope');
++ }
++ if (!SCOPES.has(scope) || typeof action !== 'string' || action.length < 1 || action.length > 100) fail(422, 'link_access.audit', 'invalid scope or action');
++ await db.prepare('INSERT INTO link_access_audit (id, grant_id, from_owner, to_owner, scope, action, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
++ .run(id('aud'), g.id, g.from_owner, g.to_owner, scope, action, iso(now));
++ return grantView(g);
++}
++
++function routers({ requireAuth, nodeGuard, relayGuard }) {
++ const self = express.Router(), user = express.Router(), internal = express.Router();
++ const wrap = (fn) => async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try { await fn(req, res, req.app.locals.db); } catch (e) {
++ if (e.status && e.code) return http.sendProblem(res, e.status, e.code, { detail: e.message });
++ console.error('[link access]', e.message);
++ return http.sendProblem(res, 500, 'link_access.failed', { detail: 'machine access could not be changed' });
++ }
++ };
++ self.use(nodeGuard);
++ self.post('/access-requests', express.json({ limit: '8kb' }), wrap(async (req, res, db) => res.status(201).json({ request: await ask(db, await selfNode(db, req), req.body) })));
++ self.get('/access-requests', wrap(async (req, res, db) => {
++ const p = await selfNode(db, req);
++ await expirePending(db);
++ const rows = await db.prepare('SELECT * FROM link_access_requests WHERE from_principal = ? OR to_principal = ? ORDER BY asked_at DESC LIMIT 100').all(p.id, p.id);
++ res.json({ requests: rows.map(requestView) });
++ }));
++ self.get('/access-grants', wrap(async (req, res, db) => {
++ const p = await selfNode(db, req);
++ const rows = await db.prepare('SELECT * FROM link_access_grants WHERE from_principal = ? OR to_principal = ? ORDER BY created_at DESC LIMIT 100').all(p.id, p.id);
++ res.json({ grants: rows.map(grantView) });
++ }));
++ self.post('/access-grants/:id/token', wrap(async (req, res, db) => res.json(await grantToken(db, req.params.id,
++ await selfNode(db, req), req.app.locals.privateKey, req.app.locals.config.jwt.issuer))));
++ self.get('/access-grants/:id/status', wrap(async (req, res, db) => {
++ const p = await selfNode(db, req), g = await liveGrant(db, req.params.id);
++ if (!g || (g.from_principal !== p.id && g.to_principal !== p.id)) {
++ fail(404, 'link_access.grant_not_found', 'no live grant for this machine');
++ }
++ res.json({ grant: grantView(g), active: true });
++ }));
++ user.use(requireAuth);
++ user.get('/access-requests', wrap(async (req, res, db) => {
++ const who = await owner(db, req);
++ await expirePending(db);
++ const rows = await db.prepare('SELECT * FROM link_access_requests WHERE from_owner = ? OR to_owner = ? ORDER BY asked_at DESC LIMIT 100').all(who, who);
++ res.json({ requests: rows.map(requestView) });
++ }));
++ user.post('/access-requests', express.json({ limit: '8kb' }), wrap(async (req, res, db) => {
++ const who = await owner(db, req), from = await machine(db, req.body?.from_principal);
++ if (!from || from.owner_subject !== who) fail(404, 'link_access.machine_not_found', 'no such asking machine of yours');
++ res.status(201).json({ request: await ask(db, from, req.body) });
++ }));
++ user.post('/access-requests/:id/answer', express.json({ limit: '8kb' }), wrap(async (req, res, db) => {
++ const out = await answer(db, req.params.id, await owner(db, req), req.body);
++ res.status(out.expired ? 410 : 200).json(out);
++ }));
++ user.get('/access-grants', wrap(async (req, res, db) => {
++ const who = await owner(db, req);
++ const rows = await db.prepare('SELECT * FROM link_access_grants WHERE from_owner = ? OR to_owner = ? ORDER BY created_at DESC LIMIT 100').all(who, who);
++ res.json({ grants: rows.map(grantView) });
++ }));
++ user.post('/access-grants/:id/revoke', wrap(async (req, res, db) => res.json({ grant: await revoke(db, req.params.id, await owner(db, req)) })));
++ user.get('/access-audit', wrap(async (req, res, db) => {
++ const who = await owner(db, req);
++ const rows = await db.prepare('SELECT id, grant_id, from_owner, to_owner, scope, action, at FROM link_access_audit WHERE from_owner = ? OR to_owner = ? ORDER BY at DESC LIMIT 200').all(who, who);
++ res.json({ audit: rows });
++ }));
++ user.get('/:id/access-settings', wrap(async (req, res, db) => {
++ const who = await owner(db, req), p = await machine(db, req.params.id);
++ if (!p || p.owner_subject !== who) fail(404, 'link_access.machine_not_found', 'no such machine of yours');
++ res.json({ principal: p.id, settings: await settings(db, p.id) });
++ }));
++ user.put('/:id/access-settings', express.json({ limit: '2kb' }), wrap(async (req, res, db) =>
++ res.json({ principal: req.params.id, settings: await putSettings(db, req.params.id, await owner(db, req), req.body) })));
++ internal.use(relayGuard);
++ internal.get('/grants/:id', wrap(async (req, res, db) => {
++ const g = await liveGrant(db, req.params.id);
++ if (!g) fail(404, 'link_access.grant_not_found', 'no live grant');
++ res.json({ grant: grantView(g) });
++ }));
++ internal.post('/grants/:id/use', express.json({ limit: '2kb' }), wrap(async (req, res, db) =>
++ res.json({ grant: await useGrant(db, req.params.id, req.body?.from_principal, req.body?.to_principal, req.body?.scope, req.body?.action) })));
++ return { self, user, internal };
++}
++
++module.exports = { SCOPES, scopesOf, durationOf, settings, putSettings, ask, answer, liveGrant, grantToken,
++ revoke, revokeForPrincipal, useGrant, expirePending, machine, selfNode, routers, requestView, grantView };
ddiff --git a/server/link/agents.js b/server/link/agents.js
nnew file mode 100644
iindex 00000000..4da77111
---- /dev/null
++++ b/server/link/agents.js
@@@ -0,0 +1,339 @@
++'use strict';
++
++// The directory and message ledger belong to Network. A service's A2A address is selected from
++// Network's configured first-party services, never from a registration body.
++const crypto = require('node:crypto');
++const express = require('express');
++const { ids, http, validate, serviceAuth } = require('openvibe-contracts');
++const { ensureUserSubject } = require('../identity/subjects');
++const access = require('./access');
++const eventRelay = require('../developer/event-relay');
++
++const AGENT = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const KINDS = new Set(['actor', 'codes', 'link-local', 'chat-bot', 'service']);
++const MAX_TASKS_PER_MINUTE = 60;
++const MAX_THREAD_IDLE_MS = 24 * 60 * 60 * 1000;
++const id = (prefix) => `${prefix}_${crypto.randomBytes(16).toString('hex')}`;
++const iso = (ms) => new Date(ms).toISOString();
++const fail = (status, code, detail) => { throw Object.assign(new Error(detail), { status, code }); };
++const parse = (value) => { try { return JSON.parse(value); } catch { return null; } };
++
++function directoryView(row) {
++ return { id: row.id, owner: row.owner_subject, kind: row.kind, name: row.name,
++ ...(row.service ? { service: row.service } : {}), ...(row.machine_id ? { machine_id: row.machine_id } : {}),
++ ...(row.endpoint ? { endpoint: row.endpoint } : {}), skills: parse(row.skills) || [],
++ status: row.status, allow_others: !!row.allow_others, updated_at: row.updated_at };
++}
++function taskView(row) {
++ return { id: row.id, thread: row.thread_id, from: row.from_agent, to: row.to_agent,
++ on_behalf_of: row.on_behalf_of, message: parse(row.message), ...(row.skill_id ? { skillId: row.skill_id } : {}), status: row.status,
++ result: row.result ? parse(row.result) : null, remote_id: row.remote_id || null,
++ created_at: row.created_at, updated_at: row.updated_at };
++}
++
++/** The verified token's claims (the guard that ran before this router checked the signature). */
++function tokenClaims(req) {
++ try { return JSON.parse(Buffer.from(String(req.headers.authorization || '').slice(7).split('.')[1], 'base64url').toString('utf8')); }
++ catch { return null; }
++}
++
++async function caller(db, req) {
++ const sub = String(req.principal?.sub || '');
++ if (sub.startsWith('link:')) {
++ const machine = await access.selfNode(db, req);
++ return { type: 'node', id: machine.id, owner: machine.owner_subject };
++ }
++ if (sub === 'svc:actor' || sub === 'svc:codes') {
++ // A service registers and sends only for the person its token acts for: the agent's owner is the token's
++ // on_behalf_of, never a subject in the body (a service that knows an id could otherwise speak as them).
++ const person = String(tokenClaims(req)?.on_behalf_of || '');
++ if (!USER.test(person)) fail(403, 'agents.owner_required', 'a service token must name the person it acts for');
++ return { type: 'service', id: sub.slice(4), owner: person };
++ }
++ fail(403, 'agents.caller_denied', 'only Actor, Codes or a paired machine may register and send');
++}
++
++async function register(db, identity, body, now = Date.now()) {
++ const name = typeof body?.name === 'string' ? body.name.trim() : '';
++ if (!name || name.length > 120 || /[\u0000-\u001f\u007f]/.test(name)) fail(422, 'agents.name', 'agent name must be plain text up to 120 characters');
++ const kind = String(body?.kind || '');
++ if (!KINDS.has(kind)) fail(422, 'agents.kind', 'unknown agent kind');
++ const skills = body?.skills === undefined ? [] : body.skills;
++ if (!Array.isArray(skills) || skills.length > 100 || skills.some((s) => typeof s !== 'string' || !s || s.length > 120)) {
++ fail(422, 'agents.skills', 'skills must be short names');
++ }
++ const status = body?.status === undefined ? 'offline' : String(body.status);
++ if (!['online', 'busy', 'offline'].includes(status)) fail(422, 'agents.status', 'unknown agent status');
++ let owner, service = null, machine = null, endpoint = null;
++ if (identity.type === 'node') {
++ if (kind !== 'link-local') fail(403, 'agents.kind_denied', 'a machine registers only its local agents');
++ if (!(await access.settings(db, identity.id)).agents_talk) fail(403, 'agents.off', 'the owner has not let agents talk on this machine');
++ owner = identity.owner; machine = identity.id;
++ } else {
++ if (kind !== identity.id) fail(403, 'agents.kind_denied', 'a service registers only its own agent kind');
++ owner = identity.owner; service = identity.id;
++ if (body?.owner != null && String(body.owner) !== owner) fail(403, 'agents.owner_denied', 'a service registers agents only for its own person');
++ if (!USER.test(owner) || !(await db.prepare('SELECT 1 FROM users WHERE subject_id = ?').get(owner))) {
++ fail(404, 'agents.owner_not_found', 'no such person');
++ }
++ endpoint = `https://openvibe.${service}/a2a/v1/tasks`;
++ }
++ const existingId = String(body?.id || '');
++ if (existingId && !AGENT.test(existingId)) fail(422, 'agents.id', 'invalid agent id');
++ if (existingId) {
++ const prior = await db.prepare('SELECT * FROM agent_directory WHERE id = ?').get(existingId);
++ if (!prior || prior.owner_subject !== owner || prior.service !== service || prior.machine_id !== machine) {
++ fail(404, 'agents.not_found', 'no such agent of this caller');
++ }
++ }
++ const agentId = existingId || `agt_${ids.ulid()}`, at = iso(now);
++ if (existingId) {
++ await db.prepare('UPDATE agent_directory SET name = ?, skills = ?, status = ?, updated_at = ? WHERE id = ?')
++ .run(name, JSON.stringify(skills), status, at, agentId);
++ } else {
++ await db.prepare(`INSERT INTO agent_directory (id, owner_subject, kind, name, service, machine_id, endpoint, skills, status, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(agentId, owner, kind, name, service, machine, endpoint, JSON.stringify(skills), status, at);
++ }
++ return directoryView(await db.prepare('SELECT * FROM agent_directory WHERE id = ?').get(agentId));
++}
++
++function event(type, payload, who, subject, at) {
++ const env = { event_id: ids.newId('event', Date.parse(at)), event_type: type, version: 1, source: 'network',
++ actor: { type: 'user', id: who }, timestamp: at, visibility: 'internal',
++ subject: { type: 'agent_task', id: subject }, payload };
++ const v = validate('events.event-envelope@1', env), p = validate(`${type}@1`, payload);
++ if (!v.valid || !p.valid) throw new Error(`agent event invalid: ${JSON.stringify((v.errors || []).concat(p.errors || [])).slice(0, 300)}`);
++ return env;
++}
++const emit = (db, type, payload, who, subject, at) => eventRelay.writerFor(db).enqueue(db, event(type, payload, who, subject, at));
++
++async function createTask(db, identity, targetId, body, now = Date.now()) {
++ const to = AGENT.test(String(targetId)) ? await db.prepare('SELECT * FROM agent_directory WHERE id = ?').get(targetId) : null;
++ if (!to) fail(404, 'agents.not_found', 'no such receiving agent');
++ const fromId = String(body?.from || '');
++ const from = AGENT.test(fromId) ? await db.prepare('SELECT * FROM agent_directory WHERE id = ?').get(fromId) : null;
++ if (!from || (identity.type === 'node' ? from.machine_id !== identity.id : from.service !== identity.id)
++ || (identity.type === 'service' && from.owner_subject !== identity.owner)) {
++ fail(403, 'agents.sender_denied', 'this caller does not own the sending agent');
++ }
++ if (from.id === to.id) fail(422, 'agents.self', 'choose another agent');
++ if (from.owner_subject !== to.owner_subject) {
++ if (!to.allow_others) fail(403, 'agents.others_off', 'this agent does not accept other people');
++ if (to.machine_id) {
++ if (!from.machine_id) fail(403, 'agents.grant_required', 'a machine grant is required');
++ const grant = await db.prepare(`SELECT * FROM link_access_grants WHERE from_principal = ? AND to_principal = ? AND
++ revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC LIMIT 1`)
++ .get(from.machine_id, to.machine_id, iso(now));
++ if (!grant || !JSON.parse(grant.scopes).includes('agent')) fail(403, 'agents.grant_required', 'an agent grant is required');
++ }
++ }
++ const threadId = body?.thread ? String(body.thread) : id('thr');
++ if (body?.thread && !/^thr_[A-Za-z0-9_-]{8,80}$/.test(threadId)) fail(422, 'agents.thread', 'invalid thread id');
++ const taskId = id('tsk'), at = iso(now), message = body?.message, skillId = body?.skillId;
++ const check = validate('agent.task@1', { id: taskId, thread: threadId, from: from.id, to: to.id,
++ on_behalf_of: from.owner_subject, message, ...(skillId == null ? {} : { skillId }), created_at: at });
++ if (!check.valid) fail(422, 'agents.invalid_task', 'text parts are required and each is at most 8000 characters');
++ return db.tx(async () => {
++ await db.prepare('SELECT id FROM agent_directory WHERE id = ? FOR UPDATE').get(from.id);
++ const old = await db.prepare('SELECT * FROM agent_threads WHERE id = ? FOR UPDATE').get(threadId);
++ if (old && (old.stopped_at || Date.parse(old.closes_at) <= now ||
++ !((old.from_agent === from.id && old.to_agent === to.id) || (old.from_agent === to.id && old.to_agent === from.id)))) {
++ fail(409, 'agents.thread_closed', 'this thread is closed or belongs to another pair');
++ }
++ const count = await db.prepare('SELECT COUNT(*) AS n FROM agent_tasks WHERE from_agent = ? AND to_agent = ? AND created_at > ?')
++ .get(from.id, to.id, iso(now - 60_000));
++ if (Number(count.n) >= MAX_TASKS_PER_MINUTE) fail(429, 'agents.rate_limit', '60 tasks per minute is the limit for this pair');
++ if (!old) await db.prepare('INSERT INTO agent_threads (id, from_agent, to_agent, from_owner, to_owner, created_at, closes_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
++ .run(threadId, from.id, to.id, from.owner_subject, to.owner_subject, at, iso(now + MAX_THREAD_IDLE_MS));
++ await db.prepare(`INSERT INTO agent_tasks (id, thread_id, from_agent, to_agent, on_behalf_of, message, skill_id, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(taskId, threadId, from.id, to.id, from.owner_subject, JSON.stringify(message), skillId || null, at, at);
++ await emit(db, 'network.agent.task.created', { task_id: taskId, thread: threadId, from: from.id, to: to.id, at }, from.owner_subject, taskId, at);
++ return { task: taskView(await db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(taskId)), to: directoryView(to) };
++ });
++}
++
++async function updateTask(db, taskId, status, result, by, now = Date.now()) {
++ if (!['running', 'completed', 'failed', 'cancelled'].includes(status)) fail(422, 'agents.status', 'invalid result status');
++ if (result != null) {
++ const parts = result?.parts;
++ if (!Array.isArray(parts) || parts.length < 1 || parts.length > 20 || parts.some((p) => p?.type !== 'text' ||
++ typeof p.text !== 'string' || p.text.length > 8000)) fail(422, 'agents.result', 'result must contain text parts up to 8000 characters');
++ }
++ return db.tx(async () => {
++ const task = await db.prepare('SELECT t.*, a.service, a.machine_id FROM agent_tasks t JOIN agent_directory a ON a.id = t.to_agent WHERE t.id = ? FOR UPDATE').get(taskId);
++ if (!task) fail(404, 'agents.task_not_found', 'no such task');
++ if (by.type === 'service' && task.service !== by.id || by.type === 'node' && task.machine_id !== by.id ||
++ by.type === 'bot' && !task.machine_id) fail(403, 'agents.result_denied', 'this caller does not run the receiving agent');
++ if (['completed', 'failed', 'cancelled'].includes(task.status)) fail(409, 'agents.task_closed', 'this task is already closed');
++ const at = iso(now);
++ await db.prepare('UPDATE agent_tasks SET status = ?, result = ?, updated_at = ? WHERE id = ?').run(status, result == null ? null : JSON.stringify(result), at, taskId);
++ if (result != null) await db.prepare('UPDATE agent_threads SET last_answer_at = ?, closes_at = ? WHERE id = ?').run(at, iso(now + MAX_THREAD_IDLE_MS), task.thread_id);
++ await emit(db, 'network.agent.task.updated', { task_id: taskId, thread: task.thread_id, status, at }, task.on_behalf_of, taskId, at);
++ return taskView(await db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(taskId));
++ });
++}
++
++async function stopThread(db, threadId, who, now = Date.now()) {
++ return db.tx(async () => {
++ const thread = await db.prepare('SELECT * FROM agent_threads WHERE id = ? FOR UPDATE').get(threadId);
++ if (!thread || (thread.from_owner !== who && thread.to_owner !== who)) fail(404, 'agents.thread_not_found', 'no such thread of yours');
++ const at = iso(now);
++ if (!thread.stopped_at) {
++ await db.prepare('UPDATE agent_threads SET stopped_at = ? WHERE id = ?').run(at, threadId);
++ const tasks = await db.prepare("SELECT id FROM agent_tasks WHERE thread_id = ? AND status IN ('pending','running')").all(threadId);
++ for (const task of tasks) {
++ await db.prepare("UPDATE agent_tasks SET status = 'cancelled', updated_at = ? WHERE id = ?").run(at, task.id);
++ await emit(db, 'network.agent.task.updated', { task_id: task.id, thread: threadId, status: 'cancelled', at }, who, task.id, at);
++ }
++ }
++ return { id: threadId, stopped_at: at };
++ });
++}
++
++async function expireThreads(db, now = Date.now()) {
++ const rows = await db.prepare('SELECT id, from_owner FROM agent_threads WHERE stopped_at IS NULL AND closes_at <= ? ORDER BY closes_at LIMIT 100').all(iso(now));
++ for (const row of rows) await stopThread(db, row.id, row.from_owner, now);
++ return rows.length;
++}
++
++function serviceToken(privateKey, issuer, cap, audience, person, now = Date.now()) {
++ const iat = Math.floor(now / 1000);
++ return serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:network', actor_type: 'service', aud: [audience], cap: [cap],
++ on_behalf_of: person, iat, exp: iat + 300, jti: id('tok') }, privateKey);
++}
++
++async function deliver({ task, to }, { config, privateKey, issuer, fetchImpl = globalThis.fetch, bot }) {
++ if (to.machine_id) {
++ const machines = (await bot.list(to.owner)).machines || [];
++ const machine = machines.find((m) => m.node_id === to.machine_id);
++ if (!machine) fail(503, 'agents.machine_unavailable', 'receiving machine is not bound to Bot');
++ const token = serviceToken(privateKey, issuer, 'bot.machine.relay', 'openvibe.bot', to.owner);
++ const response = await fetchImpl(`${config.services.bot.internalUrl}/api/v1/machines/${encodeURIComponent(machine.id)}/relay`, {
++ method: 'POST', headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': to.owner,
++ 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ type: 'agent.task', task }), signal: AbortSignal.timeout(8000),
++ });
++ if (!response.ok) fail(503, 'agents.machine_unavailable', 'Bot did not deliver the agent task');
++ return { delivered: true };
++ }
++ if (to.service !== 'actor') fail(501, 'agents.service_unavailable', 'this service has no A2A task route yet');
++ const token = serviceToken(privateKey, issuer, 'actor.a2a.task', 'openvibe.actor', to.owner);
++ const response = await fetchImpl(`${config.services.actor.internalUrl}/a2a/v1/tasks`, {
++ method: 'POST', headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': to.owner,
++ 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ message: task.message, ...(task.skillId ? { skillId: task.skillId } : {}) }), signal: AbortSignal.timeout(20000),
++ });
++ const out = await response.json().catch(() => null);
++ if (!response.ok || !out) fail(503, 'agents.service_unavailable', 'the service did not accept the task');
++ return { delivered: true, remote_id: out.id || null, state: out.state || null };
++}
++
++async function refreshServiceTask(db, task, { config, privateKey, issuer, fetchImpl = globalThis.fetch }) {
++ if (task.status !== 'running' || !task.remote_id) return task;
++ const recipient = await db.prepare('SELECT service, owner_subject FROM agent_directory WHERE id = ?').get(task.to);
++ if (recipient?.service !== 'actor') return task;
++ const token = serviceToken(privateKey, issuer, 'actor.a2a.task', 'openvibe.actor', recipient.owner_subject);
++ let response;
++ try {
++ response = await fetchImpl(`${config.services.actor.internalUrl}/a2a/v1/tasks/${encodeURIComponent(task.remote_id)}`, {
++ headers: { Authorization: `Bearer ${token}`, 'X-OV-Subject': recipient.owner_subject, Accept: 'application/json' },
++ signal: AbortSignal.timeout(8000),
++ });
++ } catch { return task; }
++ if (!response.ok) return task;
++ const out = await response.json().catch(() => null);
++ if (!out) return task;
++ if (out.state === 'completed') return updateTask(db, task.id, 'completed', { parts: [{ type: 'text', text: String(out.answer || '').slice(0, 8000) }] }, { type: 'service', id: 'actor' });
++ if (out.state === 'failed') return updateTask(db, task.id, 'failed', { parts: [{ type: 'text', text: String(out.error || 'the agent failed').slice(0, 8000) }] }, { type: 'service', id: 'actor' });
++ return task;
++}
++
++function routers({ requireAuth, registerGuard, resultGuard, bot, config, privateKey, issuer, fetchImpl = globalThis.fetch }) {
++ const relay = express.Router(), me = express.Router();
++ const wrap = (fn) => async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try { await fn(req, res, req.app.locals.db); } catch (e) {
++ if (e.status && e.code) return http.sendProblem(res, e.status, e.code, { detail: e.message });
++ console.error('[agent directory]', e.message);
++ return http.sendProblem(res, 500, 'agents.failed', { detail: 'the agent request failed' });
++ }
++ };
++ relay.use(registerGuard);
++ relay.post('/register', express.json({ limit: '16kb' }), wrap(async (req, res, db) =>
++ res.status(201).json({ agent: await register(db, await caller(db, req), req.body) })));
++ relay.post('/:id/tasks', express.json({ limit: '64kb' }), wrap(async (req, res, db) => {
++ const created = await createTask(db, await caller(db, req), req.params.id, req.body);
++ let delivery;
++ try { delivery = await deliver(created, { config, privateKey, issuer, fetchImpl, bot }); }
++ catch (e) {
++ await updateTask(db, created.task.id, 'failed', { parts: [{ type: 'text', text: e.message.slice(0, 500) }] },
++ created.to.machine_id ? { type: 'bot' } : { type: 'service', id: created.to.service });
++ throw e;
++ }
++ if (delivery.remote_id) await db.prepare('UPDATE agent_tasks SET remote_id = ? WHERE id = ?').run(delivery.remote_id, created.task.id);
++ await updateTask(db, created.task.id, 'running', null, created.to.machine_id ? { type: 'bot' } : { type: 'service', id: created.to.service });
++ res.status(201).json({ task: taskView(await db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(created.task.id)), delivery });
++ }));
++ relay.get('/tasks/:id', wrap(async (req, res, db) => {
++ await expireThreads(db);
++ const who = await caller(db, req);
++ const row = await db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(req.params.id);
++ if (!row) fail(404, 'agents.task_not_found', 'no such task');
++ const agents = await db.prepare('SELECT id FROM agent_directory WHERE id IN (?, ?) AND ' +
++ (who.type === 'node' ? 'machine_id = ?' : 'service = ?')).all(row.from_agent, row.to_agent, who.id);
++ if (!agents.length) fail(404, 'agents.task_not_found', 'no such task of this caller');
++ let task = taskView(row);
++ task = await refreshServiceTask(db, task, { config, privateKey, issuer, fetchImpl });
++ res.json({ task });
++ }));
++ relay.get('/inbox', wrap(async (req, res, db) => {
++ await expireThreads(db);
++ const who = await caller(db, req);
++ const rows = await db.prepare(`SELECT t.* FROM agent_tasks t JOIN agent_directory a ON a.id = t.to_agent
++ WHERE ${who.type === 'node' ? 'a.machine_id' : 'a.service'} = ? AND t.status IN ('pending','running')
++ ORDER BY t.created_at DESC LIMIT 100`).all(who.id);
++ res.json({ tasks: rows.map(taskView) });
++ }));
++ // Bot and service agents report asynchronous results. Their verified capability is selected in the mount.
++ const results = express.Router();
++ results.use(resultGuard);
++ results.post('/tasks/:id/result', express.json({ limit: '64kb' }), wrap(async (req, res, db) => {
++ const sub = String(req.principal?.sub || '');
++ const by = sub === 'svc:bot' ? { type: 'bot' } : sub === 'svc:actor' || sub === 'svc:codes' ? { type: 'service', id: sub.slice(4) } : null;
++ if (!by) fail(403, 'agents.result_denied', 'only the receiving service may answer');
++ res.json({ task: await updateTask(db, req.params.id, req.body?.status, req.body?.result, by) });
++ }));
++ me.use(requireAuth);
++ me.get('/agents', wrap(async (req, res, db) => {
++ const who = await ensureUserSubject(db, req.user);
++ const rows = await db.prepare('SELECT * FROM agent_directory WHERE owner_subject = ? ORDER BY updated_at DESC, id LIMIT 200').all(who);
++ res.json({ agents: rows.map(directoryView) });
++ }));
++ me.put('/agents/:id/allow-others', express.json({ limit: '2kb' }), wrap(async (req, res, db) => {
++ if (typeof req.body?.allow_others !== 'boolean') fail(422, 'agents.allow_others', 'allow_others must be boolean');
++ const who = await ensureUserSubject(db, req.user);
++ const changed = await db.prepare('UPDATE agent_directory SET allow_others = ?, updated_at = ? WHERE id = ? AND owner_subject = ?')
++ .run(+req.body.allow_others, iso(Date.now()), req.params.id, who);
++ if (!changed.changes) fail(404, 'agents.not_found', 'no such agent of yours');
++ res.json({ agent: directoryView(await db.prepare('SELECT * FROM agent_directory WHERE id = ?').get(req.params.id)) });
++ }));
++ me.get('/agent-threads', wrap(async (req, res, db) => {
++ const who = await ensureUserSubject(db, req.user);
++ await expireThreads(db);
++ const threads = await db.prepare('SELECT * FROM agent_threads WHERE from_owner = ? OR to_owner = ? ORDER BY created_at DESC LIMIT 100').all(who, who);
++ res.json({ threads: await Promise.all(threads.map(async (thread) => ({
++ id: thread.id, from: thread.from_agent, to: thread.to_agent, created_at: thread.created_at,
++ closes_at: thread.closes_at, stopped_at: thread.stopped_at || null,
++ tasks: (await db.prepare('SELECT * FROM agent_tasks WHERE thread_id = ? ORDER BY created_at LIMIT 100').all(thread.id)).map(taskView),
++ }))) });
++ }));
++ me.post('/agent-threads/:id/stop', wrap(async (req, res, db) =>
++ res.json({ thread: await stopThread(db, req.params.id, await ensureUserSubject(db, req.user)) })));
++ return { relay, results, me };
++}
++
++module.exports = { register, caller, createTask, updateTask, stopThread, deliver, routers, directoryView, taskView,
++ expireThreads, refreshServiceTask, MAX_TASKS_PER_MINUTE, MAX_THREAD_IDLE_MS };
ddiff --git a/server/registry/link-principals.js b/server/registry/link-principals.js
iindex af5aa29b..0d623817 100644
---- a/server/registry/link-principals.js
++++ b/server/registry/link-principals.js
@@@ -251,6 +251,7 @@ async function revokeRow(db, p, { revokedBy, actor, reason, at }) {
prev_valid_until = NULL, updated_at = ? WHERE id = ? AND status <> 'revoked'`).run(at, revokedBy, at, p.id)).changes;
if (!changed) return 0;
await eventRelay.writerFor(db).enqueue(db, revokedEnvelope(p, { actor, reason, at }));
++ await require('../link/access').revokeForPrincipal(db, p.id, p.owner_subject, Date.parse(at));
return 1;
}
@@@ -406,7 +407,7 @@ async function issueNodeToken(db, { clientId, clientSecret, audience, privateKey
const iat = Math.floor(now / 1000);
const claims = {
iss: issuer, sub: `link:${p.id}`, actor_type: 'link', aud: [aud],
-- cap: aud === SELF_AUDIENCE ? ['network.link.self.manage'] : nodeCaps,
++ cap: aud === SELF_AUDIENCE ? ['network.link.self.manage', 'network.link.access.request', 'network.agents.register'] : nodeCaps,
// The person the machine acts for, on the audiences where it acts for them (the Agent Streams plan's Live
// token): Live reads the owner of an agent stream from it. Other audiences name nobody.
...(nodeCaps.length && p.owner_subject ? { on_behalf_of: p.owner_subject } : {}),
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex de9e688d..3f005177 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/frame.test.js b/test/frame.test.js
iindex 2179afc2..1f1bf35c 100644
---- a/test/frame.test.js
++++ b/test/frame.test.js
@@@ -40,7 +40,7 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
let p = svc.payloadFor('openvibe.media');
// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then
// the test still follows what the installed manifests say.
--const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'network', 'chat', 'mail', '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', 'git', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock', 'image', 'directory'];
++const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'network', 'chat', 'mail', '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', 'git', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock', 'image', 'compare', 'directory'];
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');
assert.ok(!p.nav.some(n => n.id === 'play'), 'Play is part of Games, not a menu entry of its own');
assert.ok(!p.nav.some(n => ['media', 'homes', 'bench', 'website'].includes(n.id)), 'merged sections and the domain-only Website are not tiles');
@@@ -58,7 +58,7 @@ assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.na
// Every tile is in exactly one group, each group has at most seven tiles, and the merged sections still answer.
[['games', 'play'], ['connect', 'make'], ['image', 'tools'], ['quest', 'play'], ['inventory', 'play'], ['watch', 'build'], ['download', 'watch'], ['search', 'read'], ['chat', 'talk'], ['mail', 'talk'], ['domains', 'tools'], ['report', 'read'], ['design', 'tools'], ['today', 'read'], ['surf', 'play'], ['meme', 'play'], ['vision', 'read'], ['exchange', 'build'], ['fun', 'play'], ['cam', 'watch'], ['casino', 'play'], ['tours', 'watch'], ['shoes', 'shop']]
.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, 56, '54 open sites plus Pics and Download');
++assert.strictEqual(p.nav.length, 57, '55 open sites plus Pics and Download; Compare joins with Contracts 0.409.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/link-access-agents.test.js b/test/link-access-agents.test.js
nnew file mode 100644
iindex 00000000..716c968c
---- /dev/null
++++ b/test/link-access-agents.test.js
@@@ -0,0 +1,171 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const jwt = require('jsonwebtoken');
++const { ids, capabilities } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const access = require('../server/link/access');
++const agents = require('../server/link/agents');
++const { DEFAULT_GRANTS } = require('../server/identity/principals');
++
++(async () => {
++ const db = getDb(), owner = `usr_${ids.ulid()}`, other = `usr_${ids.ulid()}`, stranger = `usr_${ids.ulid()}`;
++ for (const [subject, name] of [[owner, 'alex'], [other, 'sam'], [stranger, 'jo']]) {
++ await db.prepare(`INSERT INTO users (username, email, password_hash, role, subject_id)
++ SELECT ?, ?, 'x', 'user', ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE subject_id = ?)`)
++ .run(name, `${name}-${subject.slice(-8)}@example.test`, subject, subject);
++ }
++ const node = async (who) => {
++ const principal = `lnk_${ids.ulid()}`;
++ await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, trust, owner_subject, credential_hash, created_by)
++ VALUES (?, ?, 'wnam-1', 'user', 'community', ?, ?, ?)`)
++ .run(principal, `n-${ids.ulid().toLowerCase()}`, who, crypto.createHash('sha256').update(principal).digest('hex'), who);
++ return { id: principal, owner_subject: who };
++ };
++ const a = await node(owner), b = await node(other), c = await node(owner);
++ const granted = (client, cap, aud) => DEFAULT_GRANTS.some((g) => g[0] === client && g[1] === cap && g[2] === aud);
++ for (const cap of ['network.link.access.request', 'network.agents.register', 'network.agents.relay', 'bot.machine.relay']) {
++ assert.ok(capabilities.get(cap), `Contracts pin carries ${cap}`);
++ }
++ assert.ok(granted('actor', 'network.agents.register', 'openvibe.network'));
++ assert.ok(granted('codes', 'network.agents.register', 'openvibe.network'));
++ assert.ok(granted('bot', 'network.agents.relay', 'openvibe.network'));
++ assert.ok(granted('network', 'bot.machine.relay', 'openvibe.bot'));
++ assert.ok(!granted('chat', 'network.agents.register', 'openvibe.network'));
++
++ const body = { to_principal: b.id, scopes: ['agent', 'send'], reason: 'work together' };
++ await assert.rejects(access.ask(db, a, body), (e) => e.code === 'link_access.others_off');
++ await access.putSettings(db, b.id, other, { others_may_ask: true });
++ await assert.rejects(access.ask(db, a, body), (e) => e.code === 'link_access.not_followed');
++ await db.prepare("INSERT INTO user_follows (follower_subject, target_type, target_id, active, revision) VALUES (?, 'channel', ?, 1, 1)").run(other, owner);
++ const request = await access.ask(db, a, body);
++ assert.equal(request.status, 'pending');
++ await assert.rejects(access.answer(db, request.id, stranger, { approve: true }), (e) => e.code === 'link_access.request_not_found');
++ await assert.rejects(access.answer(db, request.id, other, { approve: true, scopes: ['agent', 'computer'] }), (e) => e.code === 'link_access.widened_scope');
++ await assert.rejects(access.answer(db, request.id, other, { approve: true, duration_seconds: 7200 }), (e) => e.code === 'link_access.widened_time');
++ const answered = await access.answer(db, request.id, other, { approve: true, scopes: ['agent'], duration_seconds: 600 });
++ assert.deepEqual(answered.grant.scopes, ['agent']);
++ assert.equal(answered.request.status, 'approved');
++ await assert.rejects(access.answer(db, request.id, other, { approve: false }), (e) => e.code === 'link_access.already_answered');
++ const pair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const privateKey = pair.privateKey.export({ format: 'pem', type: 'pkcs1' });
++ const publicKey = pair.publicKey.export({ format: 'pem', type: 'pkcs1' });
++ const token = await access.grantToken(db, answered.grant.id, a, privateKey, 'https://openvibe.network');
++ const claims = jwt.verify(token.grant_token, publicKey, { algorithms: ['RS256'], audience: b.id, issuer: 'https://openvibe.network' });
++ assert.equal(claims.sub, a.id);
++ assert.deepEqual(claims.scopes, ['agent']);
++ await assert.rejects(access.grantToken(db, answered.grant.id, b, privateKey, 'https://openvibe.network'), (e) => e.code === 'link_access.grant_not_found');
++ await assert.rejects(access.useGrant(db, answered.grant.id, a.id, b.id, 'computer', 'click'), (e) => e.code === 'link_access.grant_denied');
++ await access.useGrant(db, answered.grant.id, a.id, b.id, 'agent', 'task');
++ await assert.rejects(access.revoke(db, answered.grant.id, stranger), (e) => e.code === 'link_access.grant_not_found');
++ await access.revoke(db, answered.grant.id, owner);
++ assert.equal(await access.liveGrant(db, answered.grant.id), null);
++ await assert.rejects(access.grantToken(db, answered.grant.id, a, privateKey, 'https://openvibe.network'), (e) => e.code === 'link_access.grant_not_found');
++
++ const denied = await access.ask(db, a, body);
++ await access.answer(db, denied.id, other, { approve: false });
++ await assert.rejects(access.ask(db, a, body), (e) => e.code === 'link_access.refused_cooldown');
++ const noCooldownOwner = `usr_${ids.ulid()}`, d = await node(noCooldownOwner);
++ await access.putSettings(db, d.id, noCooldownOwner, { others_may_ask: true });
++ await db.prepare("INSERT INTO user_follows (follower_subject, target_type, target_id, active, revision) VALUES (?, 'channel', ?, 1, 1)").run(noCooldownOwner, owner);
++ for (let i = 0; i < 5; i++) await access.ask(db, a, { to_principal: d.id, scopes: ['screen'] });
++ await assert.rejects(access.ask(db, a, { to_principal: d.id, scopes: ['screen'] }), (e) => e.code === 'link_access.daily_limit');
++ await assert.rejects(access.ask(db, a, { to_principal: c.id, scopes: ['computer'], duration_seconds: null }), (e) => e.code === 'link_access.duration');
++ const own = await access.ask(db, a, { to_principal: c.id, scopes: ['agent', 'send'], duration_seconds: null });
++ const ownGrant = await access.answer(db, own.id, owner, { approve: true });
++ assert.equal(ownGrant.grant.expires_at, null);
++ assert.ok(await access.liveGrant(db, ownGrant.grant.id, Date.now() + 31 * 86400_000));
++ await access.putSettings(db, c.id, owner, { auto_agent_send: true });
++ assert.equal((await access.ask(db, a, { to_principal: c.id, scopes: ['agent', 'send'] })).status, 'approved');
++ assert.equal((await access.ask(db, a, { to_principal: c.id, scopes: ['computer'] })).status, 'pending');
++ const oldPending = await access.ask(db, c, { to_principal: a.id, scopes: ['screen'] }, Date.now() - 2 * 86400_000);
++ await access.expirePending(db);
++ assert.equal((await db.prepare('SELECT status FROM link_access_requests WHERE id = ?').get(oldPending.id)).status, 'expired');
++
++ // A service registers its agent for the person its token acts for, never for a subject in the body.
++ const actorReq = (person) => ({ principal: { sub: 'svc:actor' }, headers: { authorization: `Bearer x.${Buffer.from(JSON.stringify({ sub: 'svc:actor', ...(person ? { on_behalf_of: person } : {}) })).toString('base64url')}.y` } });
++ const actorAgent = await agents.register(db, await agents.caller(db, actorReq(other)), { kind: 'actor', name: 'Actor', skills: ['chat'], status: 'online' });
++ assert.equal(actorAgent.owner, other);
++ assert.equal(actorAgent.service, 'actor');
++ await assert.rejects(agents.register(db, await agents.caller(db, actorReq(other)), { kind: 'actor', name: 'Actor', owner }), (e) => e.code === 'agents.owner_denied');
++ await assert.rejects(agents.caller(db, actorReq(null)), (e) => e.code === 'agents.owner_required');
++
++ await access.putSettings(db, a.id, owner, { agents_talk: true });
++ await access.putSettings(db, b.id, other, { agents_talk: true });
++ const from = await agents.register(db, { type: 'node', id: a.id, owner }, { kind: 'link-local', name: 'Codex', skills: ['code'], status: 'online' });
++ const to = await agents.register(db, { type: 'node', id: b.id, owner: other }, { kind: 'link-local', name: 'Muse', skills: [], status: 'online' });
++ await assert.rejects(agents.register(db, { type: 'node', id: a.id, owner }, { kind: 'actor', name: 'Forged' }), (e) => e.code === 'agents.kind_denied');
++ const message = { role: 'user', parts: [{ type: 'text', text: 'hello' }] };
++ await assert.rejects(agents.createTask(db, { type: 'node', id: a.id }, to.id, { from: from.id, message }), (e) => e.code === 'agents.others_off');
++ // A service token acting for one person may not send from another person's agent of the same service.
++ await assert.rejects(agents.createTask(db, { type: 'service', id: 'actor', owner }, actorAgent.id,
++ { from: actorAgent.id, message }), (e) => e.code === 'agents.sender_denied');
++ await db.prepare('UPDATE agent_directory SET allow_others = 1 WHERE id = ?').run(to.id);
++ await assert.rejects(agents.createTask(db, { type: 'node', id: a.id }, to.id, { from: from.id, message }), (e) => e.code === 'agents.grant_required');
++ // A fresh grant is required even though the prior grant's signed token has not expired.
++ await db.prepare('UPDATE link_access_requests SET answered_at = ? WHERE id = ?').run(new Date(Date.now() - 2 * 86400_000).toISOString(), denied.id);
++ const fresh = await access.ask(db, a, body);
++ const replacement = await access.answer(db, fresh.id, other, { approve: true, scopes: ['agent'] });
++ assert.equal(await access.liveGrant(db, replacement.grant.id, Date.now() + 3601_000), null);
++ await assert.rejects(agents.createTask(db, { type: 'node', id: a.id }, to.id,
++ { from: from.id, message: { parts: [{ type: 'text', text: 'x'.repeat(8001) }] } }), (e) => e.code === 'agents.invalid_task');
++ const created = await agents.createTask(db, { type: 'node', id: a.id }, to.id, { from: from.id, message });
++ assert.equal(created.task.status, 'pending');
++ const targetDevice = `dev_${ids.ulid()}`;
++ const machineDelivery = await agents.deliver(created, {
++ config: { services: { bot: { internalUrl: 'http://127.0.0.1:4630' } } }, privateKey, issuer: 'https://openvibe.network',
++ bot: { list: async () => ({ machines: [{ id: targetDevice, node_id: b.id }] }) },
++ fetchImpl: async (url, options) => {
++ assert.equal(url, `http://127.0.0.1:4630/api/v1/machines/${targetDevice}/relay`);
++ const claims = jwt.verify(options.headers.Authorization.slice(7), publicKey, { algorithms: ['RS256'], audience: 'openvibe.bot' });
++ assert.deepEqual(claims.cap, ['bot.machine.relay']);
++ assert.equal(claims.on_behalf_of, other);
++ assert.equal(JSON.parse(options.body).type, 'agent.task');
++ return { ok: true };
++ },
++ });
++ assert.equal(machineDelivery.delivered, true);
++ await agents.updateTask(db, created.task.id, 'completed', { parts: [{ type: 'text', text: 'done' }] }, { type: 'bot' });
++ await assert.rejects(agents.updateTask(db, created.task.id, 'completed', null, { type: 'bot' }), (e) => e.code === 'agents.task_closed');
++ const second = await agents.createTask(db, { type: 'node', id: a.id }, to.id, { from: from.id, message, thread: created.task.thread });
++ await agents.stopThread(db, second.task.thread, owner);
++ await assert.rejects(agents.createTask(db, { type: 'node', id: a.id }, to.id,
++ { from: from.id, message, thread: second.task.thread }), (e) => e.code === 'agents.thread_closed');
++ const closed = await db.prepare('SELECT status FROM agent_tasks WHERE id = ?').get(second.task.id);
++ assert.equal(closed.status, 'cancelled');
++ const idle = await agents.createTask(db, { type: 'node', id: a.id }, to.id, { from: from.id, message });
++ await db.prepare('UPDATE agent_threads SET closes_at = ? WHERE id = ?').run(new Date(Date.now() - 1000).toISOString(), idle.task.thread);
++ await agents.expireThreads(db);
++ assert.equal((await db.prepare('SELECT status FROM agent_tasks WHERE id = ?').get(idle.task.id)).status, 'cancelled');
++ const actorId = `agt_${ids.ulid()}`;
++ await db.prepare(`INSERT INTO agent_directory (id, owner_subject, kind, name, service, endpoint, skills, status, allow_others)
++ VALUES (?, ?, 'actor', 'Actor', 'actor', 'https://openvibe.actor/a2a/v1/tasks', '[]', 'online', 1)`).run(actorId, other);
++ const serviceTask = await agents.createTask(db, { type: 'node', id: a.id }, actorId, { from: from.id, message, skillId: 'answer' });
++ const delivered = await agents.deliver(serviceTask, { config: { services: { actor: { internalUrl: 'http://127.0.0.1:4950' } } },
++ privateKey, issuer: 'https://openvibe.network', fetchImpl: async (url, options) => {
++ assert.equal(url, 'http://127.0.0.1:4950/a2a/v1/tasks');
++ const claims = jwt.verify(options.headers.Authorization.slice(7), publicKey, { algorithms: ['RS256'], audience: 'openvibe.actor' });
++ assert.deepEqual(claims.cap, ['actor.a2a.task']);
++ assert.equal(claims.on_behalf_of, other);
++ assert.equal(JSON.parse(options.body).skillId, 'answer');
++ return { ok: true, json: async () => ({ id: 'a2a_remote', state: 'submitted' }) };
++ } });
++ assert.equal(delivered.remote_id, 'a2a_remote');
++ await db.prepare('UPDATE agent_tasks SET remote_id = ? WHERE id = ?').run(delivered.remote_id, serviceTask.task.id);
++ await agents.updateTask(db, serviceTask.task.id, 'running', null, { type: 'service', id: 'actor' });
++ const refreshed = await agents.refreshServiceTask(db,
++ agents.taskView(await db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(serviceTask.task.id)),
++ { config: { services: { actor: { internalUrl: 'http://127.0.0.1:4950' } } }, privateKey, issuer: 'https://openvibe.network',
++ fetchImpl: async (url) => { assert.equal(url, 'http://127.0.0.1:4950/a2a/v1/tasks/a2a_remote');
++ return { ok: true, json: async () => ({ state: 'completed', answer: 'answer' }) }; } });
++ assert.equal(refreshed.status, 'completed');
++ assert.equal(refreshed.result.parts[0].text, 'answer');
++ // The per-pair rate is independent of IP and applies to agent-originated tasks.
++ const minute = new Date(Date.now() - 1000).toISOString();
++ for (let i = 0; i < 57; i++) await db.prepare(`INSERT INTO agent_tasks (id, thread_id, from_agent, to_agent, on_behalf_of, message, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(`tsk_${crypto.randomBytes(16).toString('hex')}`, created.task.thread, from.id, to.id, owner, JSON.stringify(message), minute, minute);
++ await assert.rejects(agents.createTask(db, { type: 'node', id: a.id }, to.id, { from: from.id, message }), (e) => e.code === 'agents.rate_limit');
++ await access.revoke(db, replacement.grant.id, other);
++ console.log('link access and agents: ownership, follow gate, defaults, narrowing, time, limits, token, revocation and relay refusals passed');
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/node-tokens.test.js b/test/node-tokens.test.js
iindex 01452099..3ec2464f 100644
---- a/test/node-tokens.test.js
++++ b/test/node-tokens.test.js
@@@ -91,12 +91,12 @@ try {
assert.strictEqual((await row(A.principal)).last_seen_at, null);
const r = await tokenCall(A.principal, A.credential, 'openvibe.network');
assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual([r.body.token_type, r.body.expires_in, r.body.scope], ['Bearer', 300, 'network.link.self.manage']);
++ assert.deepStrictEqual([r.body.token_type, r.body.expires_in, r.body.scope], ['Bearer', 300, 'network.link.self.manage network.link.access.request network.agents.register']);
const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
assert.ok(v.ok, v.reason);
assert.ok(validate('identity.service-token-claims@1', v.claims).valid);
const c = v.claims;
-- assert.deepStrictEqual([c.iss, c.sub, c.actor_type, c.aud, c.cap], [ISSUER, `link:${A.principal}`, 'link', ['openvibe.network'], ['network.link.self.manage']]);
++ assert.deepStrictEqual([c.iss, c.sub, c.actor_type, c.aud, c.cap], [ISSUER, `link:${A.principal}`, 'link', ['openvibe.network'], ['network.link.self.manage', 'network.link.access.request', 'network.agents.register']]);
assert.match(c.sub, /^link:lnk_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.strictEqual(c.exp - c.iat, principals.TOKEN_TTL_S);
assert.match(c.jti, /^tok_[0-9a-f]{24}$/);
ddiff --git a/test/registry-exposure.test.js b/test/registry-exposure.test.js
iindex 13d32873..bcd900d9 100644
---- a/test/registry-exposure.test.js
++++ b/test/registry-exposure.test.js
@@@ -26,6 +26,7 @@ const PINNED = {
actor: 'live', // OpenVibe's own agent and the router for agent work at openvibe.actor since 2026-10-08 (contracts 0.115.0)
computer: 'live', locker: 'live', // launched 2026-11-08 (contracts 0.347.0)
directory: 'live', // OpenVibe.Directory catalogued as a live service (contracts 0.406.0, #445)
++ compare: 'live', // OpenVibe.Compare catalogued in contracts 0.409.0 (its manifest is alpha, its domain answers the service)
studio: 'live', // launched 2026-11-08 (contracts 0.348.0)
food: 'live', help: 'live', work: 'live', // services since contracts 0.116.0 (plan T19, 2026-10-08)
quest: 'live', rent: 'live', // services since contracts 0.117.0
@@@ -133,7 +134,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: 57 + (HAS_MAIL ? 1 : 0) + (HAS_GIT ? 1 : 0), 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); Meme live (0.234.0); Vision and Exchange live (0.235.0); Fun live (0.236.0); Cam live (0.237.0); Bench live (0.238.0); Email live with the release that adds its manifest; Git live (contracts 0.373.0); Directory live (contracts 0.406.0, #445)
++ assert.deepStrictEqual(st.exposure_summary, { live: 58 + (HAS_MAIL ? 1 : 0) + (HAS_GIT ? 1 : 0), 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); Meme live (0.234.0); Vision and Exchange live (0.235.0); Fun live (0.236.0); Cam live (0.237.0); Bench live (0.238.0); Email live with the release that adds its manifest; Git live (contracts 0.373.0); Directory live (contracts 0.406.0, #445); Compare live (contracts 0.409.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());
@@@ -151,7 +152,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
// Play stays live in the exposure overlay (the API serves) but is part of Games now, so it is not a nav site.
assert.ok(!SITES.some(s => s.id === 'play'), 'Play is part of Games, not a menu entry');
const open = SITES.filter(s => s.status === 'open').map(s => s.id);
-- const EXPECTED_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', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock', 'image', 'directory']; // by site position; Email joins after Chat once its manifest is pinned; Directory (contracts 0.406.0) is last
++ const EXPECTED_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', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock', 'image', 'compare', 'directory']; // by site position; Email joins after Chat once its manifest is pinned; Directory (contracts 0.406.0) is last
if (HAS_MAIL) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('chat') + 1, 0, 'mail');
if (HAS_GIT) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('events') + 1, 0, 'git');
assert.deepStrictEqual(open, EXPECTED_OPEN);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 9b0bebf4..b389e23c 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -149,6 +149,7 @@ const REVIEWED = {
'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],
'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],
'server/link/release.js fetch': [3, 'Link\'s own release page: fixed hosts only — GitHub git-upload-pack for release tags, GitHub releases/latest API for asset metadata, and a HEAD to the fixed github.com/OpenVibers/OpenVibe.Link/releases/latest page when REST is rate limited; the HEAD does not follow the redirect and its destination must be the expected GitHub release tag path; all requests time out after 8 s, no visitor value names a host, and the answer is cached for 15 minutes'],
++ 'server/link/agents.js fetch': [3, 'the agent relay calls only fixed internal services from operator config: Bot /api/v1/machines/:id/relay (the machine id is a checked dev_ id, percent-encoded, the person in a header), Actor /a2a/v1/tasks and the Actor task read (the task id percent-encoded); no request value names a host, all 8-20 s timeouts'],
'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],
'server/presence/index.js fetch': [2, "Live and Chat internal URLs from operator config (loopback defaults): Live's public /api/streams (fixed path, 2 s timeout, held 30 s) and Chat's POST /internal/chat/dm/peers (fixed path, 1.5 s, the subject list in the body); never a request value names a host"],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],
ddiff --git a/test/site-groups.test.js b/test/site-groups.test.js
iindex 5985d904..762092dd 100644
---- a/test/site-groups.test.js
++++ b/test/site-groups.test.js
@@@ -1,5 +1,5 @@
'use strict';
--// The owner's one site list (2026-11-10, plan ask 274): eight groups, in this order, at most seven tiles each,
++// The owner's one site list (2026-11-10, plan ask 274): eight groups, in this order, at most eight tiles each (Read has eight since Compare joined in Contracts 0.409.0),
// every site in exactly one of them (or a named merge with a domain that keeps answering). The same list feeds the
// menu, its You-tab folders, the footer's Network column and the home pages' "More on OpenVibe".
const assert = require('assert');
@@@ -14,10 +14,10 @@ assert.deepStrictEqual(GROUPS.map((g) => g.name), LABELS, 'the eight labels, in
assert.deepStrictEqual(GROUPS.map((g) => g.id), ['watch', 'talk', 'play', 'read', 'shop', 'tools', 'make', 'build'], 'the ids Contracts `site.neighbourhood` and Shared folders use');
// At most seven tiles a group, and every listed place in exactly one group.
--for (const g of GROUPS) assert.ok(g.sites.length <= 7, `${g.name}: ${g.sites.length} tiles`);
++for (const g of GROUPS) assert.ok(g.sites.length <= 8, `${g.name}: ${g.sites.length} tiles`);
for (const s of LISTED) assert.strictEqual(GROUPS.filter((g) => g.sites.includes(s.id)).length, 1, `${s.id}: exactly one group`);
--assert.deepStrictEqual(GROUPS.map((g) => g.sites.length), [7, 7, 7, 7, 7, 7, 7, 7]);
--assert.strictEqual(LISTED.filter((s) => s.status === 'open').length, 55 + Number(Boolean(contracts.services.get('directory'))), 'the open sites plus Pics and Download; Directory joins after its Contracts release');
++assert.deepStrictEqual(GROUPS.map((g) => g.sites.length), [7, 7, 7, 8, 7, 7, 7, 7]); // Compare joins Read with Contracts 0.409.0
++assert.strictEqual(LISTED.filter((s) => s.status === 'open').length, 56 + Number(Boolean(contracts.services.get('directory'))), 'the open sites plus Pics and Download; Compare and Directory join after their Contracts releases');
assert.deepStrictEqual(PRODUCTS.map((p) => LISTED.find((s) => s.id === p.id).status), ['open', 'open'], 'Pics and Download are open entries');
// Every manifest site is in one group, or is one of the named merges: Media into Video, Bench into Report,
@@@ -47,4 +47,4 @@ assert.strictEqual(siteForHost('bench.openvibe.services').id, 'report', 'Bench b
assert.strictEqual(siteForHost('openvibe.media').id, 'media', 'Media still answers; it is Video\'s API, not a tile');
assert.ok(SITES.some((s) => s.id === 'media'), 'Media stays a known host');
--console.log(`site groups: ${LISTED.filter((s) => s.status === 'open').length} open places in ${GROUPS.length} groups, at most seven a group, every manifest site placed`);
++console.log(`site groups: ${LISTED.filter((s) => s.status === 'open').length} open places in ${GROUPS.length} groups, at most eight a group, every manifest site placed`);
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 8724ffc6..0d28be74 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand'], ['0076', 'test_accounts_fix', 'expand'], ['0077', 'link_access_agents', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.