openvibers/openvibe.network Commits
Machine access requests, grants and the agent directory (Contracts 0.409.0) (#849)
Changes
nnew file mode 100644@@@ -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);@@@ -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",@@@ -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",@@@ -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',@@@ -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;@@@ -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, []],@@@ -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.nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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, privateKeyconst 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 } : {}),@@@ -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.@@@ -40,7 +40,7 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (thelet 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');nnew file mode 100644@@@ -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; });@@@ -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}$/);@@@ -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 lastif (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);@@@ -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)'],@@@ -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, inassert.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 bassert.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`);@@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -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);
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
@@ -2679,9 +2679,9 @@
@@ -2679,9 +2679,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
@@ -63,7 +63,7 @@ const GROUPS = [
@@ -63,7 +63,7 @@ const GROUPS = [
{ id: 'watch', name: 'Watch & stream', sites: ['live', 'media-hub', 'pics', 'download', 'stream', 'cam', 'tours'] },
{ 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: '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: '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: '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'] },
{ 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
// 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 = {
@@ -79,7 +79,7 @@ const MENU_LINE = {
download: 'Your files', cam: 'Your camera, in private', tours: 'Cities, routes, stays',
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',
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',
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',
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',
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',
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',
@@ -408,6 +408,14 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -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.
// 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');
const nodePrincipals = require('../registry/link-principals');
count('nodes_revoked', await nodePrincipals.revokeOwnedBy(db, subjects, { actor: { type: 'user', id: subject }, now }));
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.
// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.
for (const id of userIds) {
for (const id of userIds) {
const bal = (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;
const bal = (await db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;
@@ -31,6 +31,12 @@ const CHAT_NAMESPACES = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_setti
@@ -31,6 +31,12 @@ const CHAT_NAMESPACES = ['chat.preferences', 'chat.tts_defaults', 'chat.dm_setti
// Initial grants: what each service does against Network today.
// Initial grants: what each service does against Network today.
const DEFAULT_GRANTS = [
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.
// 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, []]] : []),
...(capabilities.get(EMAIL_READ_CAPABILITY) ? [['mail', EMAIL_READ_CAPABILITY, SELF_AUDIENCE, []]] : []),
['mail', 'identity.trust.read', SELF_AUDIENCE, []],
['mail', 'identity.trust.read', SELF_AUDIENCE, []],
@@ -641,6 +641,20 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -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/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-keys', rateLimit({ windowMs: 60_000, max: 60 }), sendRelay.keys);
app.use('/api/v1/me/nodes/send', rateLimit({ windowMs: 60_000, max: 300 }), sendRelay.web);
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
// 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.
// underneath; Network fronts it and calls it as the person, so Bot checks the machine is that person's own.
new file mode 100644
new file mode 100644
@@ -0,0 +1,346 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,339 @@
@@ -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 };
@@ -251,6 +251,7 @@ async function revokeRow(db, p, { revokedBy, actor, reason, at }) {
@@ -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;
prev_valid_until = NULL, updated_at = ? WHERE id = ? AND status <> 'revoked'`).run(at, revokedBy, at, p.id)).changes;
if (!changed) return 0;
if (!changed) return 0;
await eventRelay.writerFor(db).enqueue(db, revokedEnvelope(p, { actor, reason, at }));
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;
return 1;
}
}
@@ -406,7 +407,7 @@ async function issueNodeToken(db, { clientId, clientSecret, audience, privateKey
@@ -406,7 +407,7 @@ async function issueNodeToken(db, { clientId, clientSecret, audience, privateKey
const iat = Math.floor(now / 1000);
const iat = Math.floor(now / 1000);
const claims = {
const claims = {
iss: issuer, sub: `link:${p.id}`, actor_type: 'link', aud: [aud],
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
// 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.
// 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 } : {}),
...(nodeCaps.length && p.owner_subject ? { on_behalf_of: p.owner_subject } : {}),
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
@@ -40,7 +40,7 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
@@ -40,7 +40,7 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
let p = svc.payloadFor('openvibe.media');
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
// 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.
// 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.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 => 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');
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
@@ -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.
// 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']]
[['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}`));
.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.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -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; });
@@ -91,12 +91,12 @@ try {
@@ -91,12 +91,12 @@ try {
assert.strictEqual((await row(A.principal)).last_seen_at, null);
assert.strictEqual((await row(A.principal)).last_seen_at, null);
const r = await tokenCall(A.principal, A.credential, 'openvibe.network');
const r = await tokenCall(A.principal, A.credential, 'openvibe.network');
assert.strictEqual(r.status, 200, r.text);
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' });
const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
assert.ok(v.ok, v.reason);
assert.ok(v.ok, v.reason);
assert.ok(validate('identity.service-token-claims@1', v.claims).valid);
assert.ok(validate('identity.service-token-claims@1', v.claims).valid);
const c = v.claims;
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.match(c.sub, /^link:lnk_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.strictEqual(c.exp - c.iat, principals.TOKEN_TTL_S);
assert.strictEqual(c.exp - c.iat, principals.TOKEN_TTL_S);
assert.match(c.jti, /^tok_[0-9a-f]{24}$/);
assert.match(c.jti, /^tok_[0-9a-f]{24}$/);
@@ -26,6 +26,7 @@ const PINNED = {
@@ -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)
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)
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)
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)
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)
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
quest: 'live', rent: 'live', // services since contracts 0.117.0
@@ -133,7 +134,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
@@ -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;
// v0.95.0 Services; Space and AI went live in v0.110.0; Food, Help and Work in v0.116.0; Quest, Rent and Watch in v0.117.0;
// Inventory in v0.122.0; none is retired
// Inventory in v0.122.0; none is retired
// News, Reviews, Coupons, Trade and OpenRestream in v0.125.0; Run in v0.141.0
// News, Reviews, Coupons, Trade and OpenRestream in v0.125.0; Run in v0.141.0
- assert.deepStrictEqual(st.exposure_summary, { live: 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".
// The page: no row for a service whose domain serves a placeholder shows a bare "Up".
const html = await fetch(base + '/status').then(x => x.text());
const html = await fetch(base + '/status').then(x => x.text());
@@ -151,7 +152,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
@@ -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.
// 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');
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 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_MAIL) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('chat') + 1, 0, 'mail');
if (HAS_GIT) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('events') + 1, 0, 'git');
if (HAS_GIT) EXPECTED_OPEN.splice(EXPECTED_OPEN.indexOf('events') + 1, 0, 'git');
assert.deepStrictEqual(open, EXPECTED_OPEN);
assert.deepStrictEqual(open, EXPECTED_OPEN);
@@ -149,6 +149,7 @@ const REVIEWED = {
@@ -149,6 +149,7 @@ const REVIEWED = {
'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],
'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/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/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/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/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)'],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'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
// 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".
// menu, its You-tab folders, the footer's Network column and the home pages' "More on OpenVibe".
const assert = require('assert');
const assert = require('assert');
@@ -14,10 +14,10 @@ assert.deepStrictEqual(GROUPS.map((g) => g.name), LABELS, 'the eight labels, in
@@ -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');
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.
// 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`);
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');
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,
// 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
@@ -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.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');
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`);
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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.
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.