openvibers/openvibe.actor Commits
openvibe-contracts v0.350.0 (was v0.346.0) (#367)
Changes
@@@ -261,7 +261,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.346.0++- openvibe-contracts: v0.350.0- openvibe-sdk: v0.96.0- openvibe-shared: v4.157.0<!-- versions:end -->@@@ -7,11 +7,11 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4950,"domain": "openvibe.actor",-- "contracts": "openvibe-contracts v0.346.0",++ "contracts": "openvibe-contracts v0.350.0","sdk": "openvibe-sdk v0.96.0","shared": "openvibe-shared v4.157.0","packages": [-- "openvibe-contracts v0.346.0",++ "openvibe-contracts v0.350.0","openvibe-sdk v0.96.0","openvibe-shared v4.157.0"],@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.157.0","pg": "^8.23.0",@@@ -1679,9 +1679,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.346.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",-- "integrity": "sha512-ncmROx6i7VQa6l1FitWZhPArFEYskrIbUWTdMHKty7c5m6OIYkjh8GRm9HxzfwH9vD89isTzQiX16yfU5NYHhw==",++ "version": "0.350.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",++ "integrity": "sha512-2bGRv8VoFx3kv0jVidLU3KZwTMYJV0HoDTZ+GQtkhsaPS9kpuAKVVh6bioDsHXg9LWm1D2g6JakbrfIsgcYtQQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -28,7 +28,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.157.0","pg": "^8.23.0",@@@ -203,11 +203,7 @@ async function createApp(opts = {}) {}if (Object.keys(kept).some((k) => METHODS.has(k))) paths[p] = kept;}-- // Additive until the task-view contract release is pinned.-- const taskViewSpec = require('./task-view/openapi.json');-- paths['/api/v1/tasks/{id}/view-ticket'] = taskViewSpec.path;-- const components = { ...doc.components, schemas: { ...doc.components.schemas, 'actor.task-view-ticket.v1': taskViewSpec.schema } };-- res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths, components });++ res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });});app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);@@@ -319,7 +315,6 @@ async function createApp(opts = {}) {// OpenVibe.Services reads Actor's task index through a first-party service token on loopback.app.use('/api/v1/resources', require('./registry/resource-control').router(ctx));app.use('/api/v1/resources', resourceIndex.router(ctx));-- app.use('/api/v1', taskView.api);app.use('/api/v1', createApi(ctx));// A2A (stage V2-11): another agent hands Actor a task. The card is /.well-known/agent.json, below.app.use(createA2APages(ctx));@@@ -29,6 +29,7 @@ const express = require('express');const contracts = require('openvibe-contracts');const { asyncRouter } = require('./router');const store = require('../tasks/store');++const tickets = require('../task-view/tickets');const catalog = require('../agents/catalog');const { heuristic } = require('../agents/classify');const { route, MODES } = require('../agents/router');@@@ -631,6 +632,18 @@ function createApi(ctx) {res.json(out);});++ // Only the task owner's own Bearer may mint a view ticket. A session or delegated token cannot.++ r.post('/tasks/:id/view-ticket', principal.requireCapability('actor.task.view_ticket'), limits.reads('actor.task.view_ticket'), async (req, res) => {++ const p = req.principal;++ if (p.kind !== 'user' || p.viaSession || !req.get('authorization')?.startsWith('Bearer ')) return res.sendStatus(403);++ try {++ const row = await store.getTask(s, req.params.id);++ if (!row || row.requester !== p.requester) return res.sendStatus(404);++ const issued = await tickets.issue(s, row);++ res.json({ url: `${config.baseUrl}/embed/task/${encodeURIComponent(row.id)}?t=${issued.ticket}`, expires_at: issued.expires_at });++ } catch { ctx.log.warn('[Actor] task view issue failed', req.params.id); res.sendStatus(503); }++ });++// Take over / Give it back (plan 2026-11-07), built on the hand_to_person machinery: the person drives the desk// they were handed, the agent's input is refused while they do, and the task's timeline gets one hand-off step.// Only the task's own people (load()), and only while it runs on a Computer.@@@ -30,7 +30,7 @@ const subject = require('./subject');const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;--const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];++const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];function decodePayload(token) {const parts = String(token || '').split('.');ddeleted file mode 100644@@@ -1,79 +0,0 @@--{-- "path": {-- "post": {-- "operationId": "post_api_v1_tasks_id_view_ticket",-- "summary": "Mint a 15-minute reusable read-only task embed ticket.",-- "description": "**actor.task.view_ticket** (public): Mint a 15-minute reusable read-only task embed ticket. Only the person's own Bearer token and their own task are accepted; session cookies, service and delegated tokens cannot mint. Another person's task returns 404. Tickets work only on /embed/task/:id and its events/still reads, never on a write route. Wrong-task or expired tickets return 403. Stored only as SHA-256 hashes, bound to task and requester.",-- "tags": [-- "actor.task"-- ],-- "x-openvibe-capabilities": [-- "actor.task.view_ticket"-- ],-- "x-openvibe-visibility": [-- "public"-- ],-- "x-openvibe-quota-class": "actor-read",-- "security": [-- {-- "openvibeToken": [-- "actor.task.view_ticket"-- ]-- }-- ],-- "parameters": [-- {-- "name": "id",-- "in": "path",-- "required": true,-- "schema": {-- "type": "string"-- }-- }-- ],-- "responses": {-- "2XX": {-- "description": "Success",-- "content": {-- "application/json": {-- "schema": {-- "$ref": "#/components/schemas/actor.task-view-ticket.v1"-- }-- }-- }-- },-- "default": {-- "description": "An error (RFC 9457 problem details)",-- "content": {-- "application/problem+json": {-- "schema": {-- "$ref": "#/components/schemas/errors.problem.v1"-- }-- }-- }-- }-- }-- }-- },-- "schema": {-- "title": "ActorTaskViewTicket",-- "description": "actor.task-view-ticket@1: a reusable read-only view of one task for its owner, valid for 15 minutes. The URL contains a bearer credential; never log it or forward it in a referrer.",-- "type": "object",-- "additionalProperties": false,-- "required": [-- "url",-- "expires_at"-- ],-- "properties": {-- "url": {-- "type": "string",-- "format": "uri",-- "pattern": "^https?://[^/?#]+/embed/task/[^/?#]+\\?t=tvt_[A-Za-z0-9_-]{43}$"-- },-- "expires_at": {-- "type": "string",-- "format": "date-time"-- }-- }-- }--}@@@ -6,21 +6,9 @@ const { createFrames } = require('./frames');const { html, render } = require('../render/html');function createTaskView(ctx, { fetchImpl } = {}) {-- const { s, principal, config, stream, computers, log } = ctx;++ const { s, config, stream, computers, log } = ctx;const origins = createFrames({ fetchImpl, networkUrl: config.networkInternalUrl });-- const api = express.Router(), embed = express.Router();-- // Only the person's own Bearer may mint; delegated apps and sessions cannot.-- api.post('/tasks/:id/view-ticket', principal.middleware, async (req, res) => {-- res.set('Cache-Control', 'no-store');-- const p = req.principal;-- if (!p || p.kind !== 'user' || p.viaSession || !req.get('authorization')?.startsWith('Bearer ')) return res.sendStatus(403);-- try {-- const row = await tasks.getTask(s, req.params.id);-- if (!row || row.requester !== p.requester) return res.sendStatus(404);-- const issued = await tickets.issue(s, row);-- res.json({ url: `${config.baseUrl}/embed/task/${encodeURIComponent(row.id)}?t=${issued.ticket}`, expires_at: issued.expires_at });-- } catch { log.warn('[Actor] task view issue failed', req.params.id); res.sendStatus(503); }-- });++ const embed = express.Router();embed.use('/embed/task', (_req, res, next) => {res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex' });next();@@@ -75,6 +63,6 @@ function createTaskView(ctx, { fetchImpl } = {}) {});// No request under this prefix reaches session-authenticated pages or write routes.embed.all('/embed/task/:id*', (_req, res) => res.sendStatus(405));-- return { api, embed };++ return { embed };}module.exports = { createTaskView };@@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');const configLib = require('../server/config');const { boot, check, done } = require('./helpers/boot');--const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];++const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads@@@ -67,8 +67,8 @@ function codeRoutes() {const out = new Map();for (const [source, prefix] of [[API_SRC, '/api/v1'], [INDEX_SRC, '/api/v1/resources'], [CONTROL_SRC, '/api/v1/resources'], [A2A_SRC, '']]) {for (const m of source.matchAll(/^\s*r\.(get|post|put|patch|delete)\('([^']+)'(.*)$/gm)) {-- const guard = m[3].match(/requireCapability\('([a-z0-9.]+)'\)/);-- const read = m[3].match(/limits\.reads\('([a-z0-9.]+)'\)/);++ const guard = m[3].match(/requireCapability\('([a-z0-9._]+)'\)/);++ const read = m[3].match(/limits\.reads\('([a-z0-9._]+)'\)/);out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });}}@@@ -119,7 +119,7 @@ function codeRoutes() {assert.strictEqual(c.owner, 'actor', id);}const principalSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'principal.js'), 'utf8');-- const listed = principalSrc.match(/const CAPABILITIES = \[([^\]]+)\]/)[1].match(/'([a-z0-9.]+)'/g).map((s) => s.slice(1, -1));++ const listed = principalSrc.match(/const CAPABILITIES = \[([^\]]+)\]/)[1].match(/'([a-z0-9._]+)'/g).map((s) => s.slice(1, -1));assert.deepStrictEqual([...listed].sort(), [...CAPS].sort(), 'principal.js accepts exactly Actor\'s capabilities');const appSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');assert.match(appSrc, /app\.use\('\/api\/v1\/resources', resourceIndex\.router\(ctx\)\)/);@@@ -1,5 +1,7 @@'use strict';const assert = require('assert');++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');const fs = require('fs');const path = require('path');const nginx = fs.readFileSync(path.join(__dirname, '../deploy/nginx/openvibe.actor.conf'), 'utf8');@@@ -21,6 +23,19 @@ const { createFrames } = require('../server/task-view/frames');const mint = (id, options = { bearer }) => t.get(`/api/v1/tasks/${id}/view-ticket`, { method: 'POST', ...options });assert.equal((await mint(theirs.id)).status, 404);assert.equal((await mint(row.id, { as: me })).status, 403);++ const nowSeconds = Math.floor(Date.now() / 1000);++ const token = (sub, actorType, extra = {}) => serviceAuth.signServiceToken({++ iss: t.network.url, sub, actor_type: actorType, aud: ['openvibe.actor'], cap: ['actor.task.view_ticket'],++ iat: nowSeconds, exp: nowSeconds + 300, jti: crypto.randomUUID(), ...extra,++ }, t.network.privatePem);++ const service = token('svc:watch', 'service');++ assert.equal((await mint(row.id, { bearer: service })).status, 403);++ assert.equal((await mint(row.id, { bearer: service, headers: { 'X-OV-Subject': me.subject } })).status, 403);++ const delegated = token('app:app_01JAB2C3D4E5F6G7H8J9K0MNPT', 'app', {++ on_behalf_of: me.subject, project_id: 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR',++ ns: ['prj_01JAB2C3D4E5F6G7H8J9K0MNPR'], env: 'production',++ });++ assert.equal((await mint(row.id, { bearer: delegated, headers: { 'X-OV-Subject': me.subject } })).status, 403);const issued = await mint(row.id);assert.equal(issued.status, 200);const u = new URL(issued.json().url), raw = u.searchParams.get('t');
@@ -261,7 +261,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -261,7 +261,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.346.0
+- openvibe-contracts: v0.350.0
- openvibe-sdk: v0.96.0
- openvibe-sdk: v0.96.0
- openvibe-shared: v4.157.0
- openvibe-shared: v4.157.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4950,
"port": 4950,
"domain": "openvibe.actor",
"domain": "openvibe.actor",
- "contracts": "openvibe-contracts v0.346.0",
+ "contracts": "openvibe-contracts v0.350.0",
"sdk": "openvibe-sdk v0.96.0",
"sdk": "openvibe-sdk v0.96.0",
"shared": "openvibe-shared v4.157.0",
"shared": "openvibe-shared v4.157.0",
"packages": [
"packages": [
- "openvibe-contracts v0.346.0",
+ "openvibe-contracts v0.350.0",
"openvibe-sdk v0.96.0",
"openvibe-sdk v0.96.0",
"openvibe-shared v4.157.0"
"openvibe-shared v4.157.0"
],
],
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.157.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.157.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1679,9 +1679,9 @@
@@ -1679,9 +1679,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.346.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
- "integrity": "sha512-ncmROx6i7VQa6l1FitWZhPArFEYskrIbUWTdMHKty7c5m6OIYkjh8GRm9HxzfwH9vD89isTzQiX16yfU5NYHhw==",
+ "version": "0.350.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",
+ "integrity": "sha512-2bGRv8VoFx3kv0jVidLU3KZwTMYJV0HoDTZ+GQtkhsaPS9kpuAKVVh6bioDsHXg9LWm1D2g6JakbrfIsgcYtQQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.157.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.157.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -203,11 +203,7 @@ async function createApp(opts = {}) {
@@ -203,11 +203,7 @@ async function createApp(opts = {}) {
}
}
if (Object.keys(kept).some((k) => METHODS.has(k))) paths[p] = kept;
if (Object.keys(kept).some((k) => METHODS.has(k))) paths[p] = kept;
}
}
- // Additive until the task-view contract release is pinned.
- const taskViewSpec = require('./task-view/openapi.json');
- paths['/api/v1/tasks/{id}/view-ticket'] = taskViewSpec.path;
- const components = { ...doc.components, schemas: { ...doc.components.schemas, 'actor.task-view-ticket.v1': taskViewSpec.schema } };
- res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths, components });
+ res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });
});
});
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -319,7 +315,6 @@ async function createApp(opts = {}) {
@@ -319,7 +315,6 @@ async function createApp(opts = {}) {
// OpenVibe.Services reads Actor's task index through a first-party service token on loopback.
// OpenVibe.Services reads Actor's task index through a first-party service token on loopback.
app.use('/api/v1/resources', require('./registry/resource-control').router(ctx));
app.use('/api/v1/resources', require('./registry/resource-control').router(ctx));
app.use('/api/v1/resources', resourceIndex.router(ctx));
app.use('/api/v1/resources', resourceIndex.router(ctx));
- app.use('/api/v1', taskView.api);
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
// A2A (stage V2-11): another agent hands Actor a task. The card is /.well-known/agent.json, below.
// A2A (stage V2-11): another agent hands Actor a task. The card is /.well-known/agent.json, below.
app.use(createA2APages(ctx));
app.use(createA2APages(ctx));
@@ -29,6 +29,7 @@ const express = require('express');
@@ -29,6 +29,7 @@ const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const store = require('../tasks/store');
const store = require('../tasks/store');
+const tickets = require('../task-view/tickets');
const catalog = require('../agents/catalog');
const catalog = require('../agents/catalog');
const { heuristic } = require('../agents/classify');
const { heuristic } = require('../agents/classify');
const { route, MODES } = require('../agents/router');
const { route, MODES } = require('../agents/router');
@@ -631,6 +632,18 @@ function createApi(ctx) {
@@ -631,6 +632,18 @@ function createApi(ctx) {
res.json(out);
res.json(out);
});
});
+ // Only the task owner's own Bearer may mint a view ticket. A session or delegated token cannot.
+ r.post('/tasks/:id/view-ticket', principal.requireCapability('actor.task.view_ticket'), limits.reads('actor.task.view_ticket'), async (req, res) => {
+ const p = req.principal;
+ if (p.kind !== 'user' || p.viaSession || !req.get('authorization')?.startsWith('Bearer ')) return res.sendStatus(403);
+ try {
+ const row = await store.getTask(s, req.params.id);
+ if (!row || row.requester !== p.requester) return res.sendStatus(404);
+ const issued = await tickets.issue(s, row);
+ res.json({ url: `${config.baseUrl}/embed/task/${encodeURIComponent(row.id)}?t=${issued.ticket}`, expires_at: issued.expires_at });
+ } catch { ctx.log.warn('[Actor] task view issue failed', req.params.id); res.sendStatus(503); }
+ });
+
// Take over / Give it back (plan 2026-11-07), built on the hand_to_person machinery: the person drives the desk
// Take over / Give it back (plan 2026-11-07), built on the hand_to_person machinery: the person drives the desk
// they were handed, the agent's input is refused while they do, and the task's timeline gets one hand-off step.
// they were handed, the agent's input is refused while they do, and the task's timeline gets one hand-off step.
// Only the task's own people (load()), and only while it runs on a Computer.
// Only the task's own people (load()), and only while it runs on a Computer.
@@ -30,7 +30,7 @@ const subject = require('./subject');
@@ -30,7 +30,7 @@ const subject = require('./subject');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
-const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];
+const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
deleted file mode 100644
deleted file mode 100644
@@ -1,79 +0,0 @@
@@ -1,79 +0,0 @@
-{
- "path": {
- "post": {
- "operationId": "post_api_v1_tasks_id_view_ticket",
- "summary": "Mint a 15-minute reusable read-only task embed ticket.",
- "description": "**actor.task.view_ticket** (public): Mint a 15-minute reusable read-only task embed ticket. Only the person's own Bearer token and their own task are accepted; session cookies, service and delegated tokens cannot mint. Another person's task returns 404. Tickets work only on /embed/task/:id and its events/still reads, never on a write route. Wrong-task or expired tickets return 403. Stored only as SHA-256 hashes, bound to task and requester.",
- "tags": [
- "actor.task"
- ],
- "x-openvibe-capabilities": [
- "actor.task.view_ticket"
- ],
- "x-openvibe-visibility": [
- "public"
- ],
- "x-openvibe-quota-class": "actor-read",
- "security": [
- {
- "openvibeToken": [
- "actor.task.view_ticket"
- ]
- }
- ],
- "parameters": [
- {
- "name": "id",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- }
- ],
- "responses": {
- "2XX": {
- "description": "Success",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/actor.task-view-ticket.v1"
- }
- }
- }
- },
- "default": {
- "description": "An error (RFC 9457 problem details)",
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "#/components/schemas/errors.problem.v1"
- }
- }
- }
- }
- }
- }
- },
- "schema": {
- "title": "ActorTaskViewTicket",
- "description": "actor.task-view-ticket@1: a reusable read-only view of one task for its owner, valid for 15 minutes. The URL contains a bearer credential; never log it or forward it in a referrer.",
- "type": "object",
- "additionalProperties": false,
- "required": [
- "url",
- "expires_at"
- ],
- "properties": {
- "url": {
- "type": "string",
- "format": "uri",
- "pattern": "^https?://[^/?#]+/embed/task/[^/?#]+\\?t=tvt_[A-Za-z0-9_-]{43}$"
- },
- "expires_at": {
- "type": "string",
- "format": "date-time"
- }
- }
- }
-}
@@ -6,21 +6,9 @@ const { createFrames } = require('./frames');
@@ -6,21 +6,9 @@ const { createFrames } = require('./frames');
const { html, render } = require('../render/html');
const { html, render } = require('../render/html');
function createTaskView(ctx, { fetchImpl } = {}) {
function createTaskView(ctx, { fetchImpl } = {}) {
- const { s, principal, config, stream, computers, log } = ctx;
+ const { s, config, stream, computers, log } = ctx;
const origins = createFrames({ fetchImpl, networkUrl: config.networkInternalUrl });
const origins = createFrames({ fetchImpl, networkUrl: config.networkInternalUrl });
- const api = express.Router(), embed = express.Router();
- // Only the person's own Bearer may mint; delegated apps and sessions cannot.
- api.post('/tasks/:id/view-ticket', principal.middleware, async (req, res) => {
- res.set('Cache-Control', 'no-store');
- const p = req.principal;
- if (!p || p.kind !== 'user' || p.viaSession || !req.get('authorization')?.startsWith('Bearer ')) return res.sendStatus(403);
- try {
- const row = await tasks.getTask(s, req.params.id);
- if (!row || row.requester !== p.requester) return res.sendStatus(404);
- const issued = await tickets.issue(s, row);
- res.json({ url: `${config.baseUrl}/embed/task/${encodeURIComponent(row.id)}?t=${issued.ticket}`, expires_at: issued.expires_at });
- } catch { log.warn('[Actor] task view issue failed', req.params.id); res.sendStatus(503); }
- });
+ const embed = express.Router();
embed.use('/embed/task', (_req, res, next) => {
embed.use('/embed/task', (_req, res, next) => {
res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex' });
res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex' });
next();
next();
@@ -75,6 +63,6 @@ function createTaskView(ctx, { fetchImpl } = {}) {
@@ -75,6 +63,6 @@ function createTaskView(ctx, { fetchImpl } = {}) {
});
});
// No request under this prefix reaches session-authenticated pages or write routes.
// No request under this prefix reaches session-authenticated pages or write routes.
embed.all('/embed/task/:id*', (_req, res) => res.sendStatus(405));
embed.all('/embed/task/:id*', (_req, res) => res.sendStatus(405));
- return { api, embed };
+ return { embed };
}
}
module.exports = { createTaskView };
module.exports = { createTaskView };
@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
-const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];
+const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is
// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is
// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads
// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads
@@ -67,8 +67,8 @@ function codeRoutes() {
@@ -67,8 +67,8 @@ function codeRoutes() {
const out = new Map();
const out = new Map();
for (const [source, prefix] of [[API_SRC, '/api/v1'], [INDEX_SRC, '/api/v1/resources'], [CONTROL_SRC, '/api/v1/resources'], [A2A_SRC, '']]) {
for (const [source, prefix] of [[API_SRC, '/api/v1'], [INDEX_SRC, '/api/v1/resources'], [CONTROL_SRC, '/api/v1/resources'], [A2A_SRC, '']]) {
for (const m of source.matchAll(/^\s*r\.(get|post|put|patch|delete)\('([^']+)'(.*)$/gm)) {
for (const m of source.matchAll(/^\s*r\.(get|post|put|patch|delete)\('([^']+)'(.*)$/gm)) {
- const guard = m[3].match(/requireCapability\('([a-z0-9.]+)'\)/);
- const read = m[3].match(/limits\.reads\('([a-z0-9.]+)'\)/);
+ const guard = m[3].match(/requireCapability\('([a-z0-9._]+)'\)/);
+ const read = m[3].match(/limits\.reads\('([a-z0-9._]+)'\)/);
out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
}
}
}
}
@@ -119,7 +119,7 @@ function codeRoutes() {
@@ -119,7 +119,7 @@ function codeRoutes() {
assert.strictEqual(c.owner, 'actor', id);
assert.strictEqual(c.owner, 'actor', id);
}
}
const principalSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'principal.js'), 'utf8');
const principalSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'principal.js'), 'utf8');
- const listed = principalSrc.match(/const CAPABILITIES = \[([^\]]+)\]/)[1].match(/'([a-z0-9.]+)'/g).map((s) => s.slice(1, -1));
+ const listed = principalSrc.match(/const CAPABILITIES = \[([^\]]+)\]/)[1].match(/'([a-z0-9._]+)'/g).map((s) => s.slice(1, -1));
assert.deepStrictEqual([...listed].sort(), [...CAPS].sort(), 'principal.js accepts exactly Actor\'s capabilities');
assert.deepStrictEqual([...listed].sort(), [...CAPS].sort(), 'principal.js accepts exactly Actor\'s capabilities');
const appSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');
const appSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');
assert.match(appSrc, /app\.use\('\/api\/v1\/resources', resourceIndex\.router\(ctx\)\)/);
assert.match(appSrc, /app\.use\('\/api\/v1\/resources', resourceIndex\.router\(ctx\)\)/);
@@ -1,5 +1,7 @@
@@ -1,5 +1,7 @@
'use strict';
'use strict';
const assert = require('assert');
const assert = require('assert');
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const nginx = fs.readFileSync(path.join(__dirname, '../deploy/nginx/openvibe.actor.conf'), 'utf8');
const nginx = fs.readFileSync(path.join(__dirname, '../deploy/nginx/openvibe.actor.conf'), 'utf8');
@@ -21,6 +23,19 @@ const { createFrames } = require('../server/task-view/frames');
@@ -21,6 +23,19 @@ const { createFrames } = require('../server/task-view/frames');
const mint = (id, options = { bearer }) => t.get(`/api/v1/tasks/${id}/view-ticket`, { method: 'POST', ...options });
const mint = (id, options = { bearer }) => t.get(`/api/v1/tasks/${id}/view-ticket`, { method: 'POST', ...options });
assert.equal((await mint(theirs.id)).status, 404);
assert.equal((await mint(theirs.id)).status, 404);
assert.equal((await mint(row.id, { as: me })).status, 403);
assert.equal((await mint(row.id, { as: me })).status, 403);
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const token = (sub, actorType, extra = {}) => serviceAuth.signServiceToken({
+ iss: t.network.url, sub, actor_type: actorType, aud: ['openvibe.actor'], cap: ['actor.task.view_ticket'],
+ iat: nowSeconds, exp: nowSeconds + 300, jti: crypto.randomUUID(), ...extra,
+ }, t.network.privatePem);
+ const service = token('svc:watch', 'service');
+ assert.equal((await mint(row.id, { bearer: service })).status, 403);
+ assert.equal((await mint(row.id, { bearer: service, headers: { 'X-OV-Subject': me.subject } })).status, 403);
+ const delegated = token('app:app_01JAB2C3D4E5F6G7H8J9K0MNPT', 'app', {
+ on_behalf_of: me.subject, project_id: 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR',
+ ns: ['prj_01JAB2C3D4E5F6G7H8J9K0MNPR'], env: 'production',
+ });
+ assert.equal((await mint(row.id, { bearer: delegated, headers: { 'X-OV-Subject': me.subject } })).status, 403);
const issued = await mint(row.id);
const issued = await mint(row.id);
assert.equal(issued.status, 200);
assert.equal(issued.status, 200);
const u = new URL(issued.json().url), raw = u.searchParams.get('t');
const u = new URL(issued.json().url), raw = u.searchParams.get('t');