openvibers/openvibe.actor Commits

Add read-only task overlay views with scoped tickets (#356)

OP OpenVibers committed 5 days ago · 2f92e25eee · 13 files, +433 −2

* Add scoped read-only task overlays for the Dock

* Keep task view privacy headers on rate limit responses

* Register Network frame fetch and avoid concurrent migration number

Changes

ddiff --git a/deploy/nginx/openvibe.actor.conf b/deploy/nginx/openvibe.actor.conf
iindex 75a7150..946ca0e 100644
---- a/deploy/nginx/openvibe.actor.conf
++++ b/deploy/nginx/openvibe.actor.conf
@@@ -10,6 +10,9 @@
## visitors; private, no-store for everything personal). Never log request bodies here: tasks are people's words.
## ═══════════════════════════════════════════════════════════════
++# Query strings can carry task-view credentials. Never include $request, $args or referrers.
++log_format ovactor_safe '$remote_addr $request_method $uri $status';
++
limit_req_zone $binary_remote_addr zone=ovactor_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=ovactor_api:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=ovactor_forms:10m rate=1r/s;
@@@ -18,6 +21,8 @@ server {
listen 80;
listen [::]:80;
server_name openvibe.actor www.openvibe.actor;
++ access_log /var/log/nginx/openvibe.actor.access.log ovactor_safe;
++ error_log /dev/null crit;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://openvibe.actor$request_uri; }
@@@ -28,6 +33,8 @@ server {
listen [::]:443 ssl;
http2 on;
server_name www.openvibe.actor;
++ access_log /var/log/nginx/openvibe.actor.access.log ovactor_safe;
++ error_log /dev/null crit;
ssl_certificate /etc/letsencrypt/live/openvibe.actor/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.actor/privkey.pem;
@@@ -47,7 +54,7 @@ server {
ssl_certificate_key /etc/letsencrypt/live/openvibe.actor/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
-- access_log /var/log/nginx/openvibe.actor.access.log;
++ access_log /var/log/nginx/openvibe.actor.access.log ovactor_safe;
error_log /var/log/nginx/openvibe.actor.error.log;
# A task is at most 20000 characters; the API's JSON cap is 64 KB.
@@@ -70,6 +77,18 @@ server {
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
++ # The task-view credential stays out of access/error logs; SSE is never buffered.
++ location ^~ /embed/task/ {
++ error_log /dev/null crit;
++ limit_req zone=ovactor_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_buffering off;
++ proxy_cache off;
++ proxy_read_timeout 300s;
++ gzip off;
++ proxy_pass http://127.0.0.1:4950;
++ }
++
# ── Network SSO session endpoints ───────────────────────
# The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
location = /auth/me {
ddiff --git a/docs/task-view.md b/docs/task-view.md
nnew file mode 100644
iindex 0000000..26b0243
---- /dev/null
++++ b/docs/task-view.md
@@@ -0,0 +1,13 @@
++# Task overlay view
++
++`POST /api/v1/tasks/:id/view-ticket` with the person's own `Authorization: Bearer …` returns `{ url, expires_at }`. Only the task's requester can mint it; another person's task is 404. Sessions and delegated/service credentials cannot mint tickets. The URL opens `/embed/task/:id?t=tvt_…` without cookies.
++
++A ticket is 32 random bytes encoded as base64url, stored only as SHA-256, bound to the task and requester, and reusable for 15 minutes. It authorizes only the embed, its `/events` stream and `/still` image. Wrong-task, missing and expired tickets return 403; the page shows “This view has expired”. An open stream closes at expiry. No normal API route accepts these tickets. Account/task deletion cascades to the ticket rows; minting removes expired rows.
++
++The embed renders the title, state, task events and a polled computer image (Run sessions or older Bot desks). “Open in Actor” navigates the top frame to the task's page. It has no controls, approval forms, session probes or site chrome. Event text uses textContent, never HTML. Credentials are read from the URL by the external script, never embedded in returned HTML or navigation links. All embed responses are no-store and no-referrer.
++
++Only `/embed/task/*` relaxes frame ancestors. Network's `/api/frame?host=openvibe.actor` supplies the first-party hosts, cached for ten minutes; `server/task-view/frame-origins.json` is the checked-in fallback derived from Contracts' site manifests. Other pages keep `frame-ancestors 'none'` and X-Frame-Options DENY.
++
++Deployment must install `deploy/nginx/openvibe.actor.conf` along with the app: access logs omit query strings and referrers, embed error logging is suppressed, and streaming responses are unbuffered. Do not log the returned URL in a caller or edge proxy. Application warnings identify only the task id.
++
++The companion Contracts PR adds `actor.task-view-ticket@1` and the person-only `actor.task.view_ticket` operation. Until its release is pinned, `server/task-view/openapi.json` adds the same operation/schema to Actor's served OpenAPI document. No Contracts release or deployment is part of this PR.
ddiff --git a/migrations/0052_task_view_tickets.sql b/migrations/0052_task_view_tickets.sql
nnew file mode 100644
iindex 0000000..c831699
---- /dev/null
++++ b/migrations/0052_task_view_tickets.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++CREATE TABLE task_view_tickets (
++ token_hash text PRIMARY KEY,
++ task_id text NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
++ requester text NOT NULL,
++ expires_at timestamptz NOT NULL
++);
++CREATE INDEX task_view_tickets_expiry ON task_view_tickets(expires_at);
ddiff --git a/public/css/task-view.css b/public/css/task-view.css
nnew file mode 100644
iindex 0000000..052a05d
---- /dev/null
++++ b/public/css/task-view.css
@@@ -0,0 +1,9 @@
++:root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; }
++body { margin: 0; }
++main { padding: 16px; max-width: 70rem; margin: auto; overflow-wrap: anywhere; }
++h1 { font-size: 1.1rem; margin: 0; white-space: pre-wrap; }
++#state { opacity: .7; margin: 8px 0; }
++#desk { width: 100%; max-height: 60vh; object-fit: contain; border-radius: 8px; }
++ol { padding-left: 24px; }
++li { white-space: pre-wrap; margin-bottom: 8px; }
++a { display: inline-block; padding: 8px 12px; border: 1px solid currentColor; border-radius: 8px; color: inherit; }
ddiff --git a/public/js/task-view.js b/public/js/task-view.js
nnew file mode 100644
iindex 0000000..fb1d1ab
---- /dev/null
++++ b/public/js/task-view.js
@@@ -0,0 +1,41 @@
++'use strict';
++(() => {
++ const base = location.pathname, ticket = new URL(location.href).searchParams.get('t');
++ const url = (part) => `${base}/${part}?t=${encodeURIComponent(ticket || '')}`;
++ const state = document.getElementById('state'), steps = document.getElementById('steps'), desk = document.getElementById('desk');
++ let stopped = false, picture;
++ const events = new EventSource(url('events'), { withCredentials: false });
++ function stop(expired) {
++ stopped = true;
++ events.close();
++ clearInterval(poll);
++ if (picture) URL.revokeObjectURL(picture);
++ desk.hidden = true;
++ if (expired) { state.textContent = 'This view has expired'; steps.replaceChildren(); }
++ }
++ function append(e) {
++ const data = JSON.parse(e.data);
++ const li = document.createElement('li');
++ li.textContent = data.text || data.title || data.step || data.kind;
++ steps.append(li);
++ if (data.state) state.textContent = data.state;
++ if (data.kind === 'end') { state.textContent = data.state || 'Finished'; events.close(); }
++ }
++ for (const kind of ['state', 'step', 'output', 'error', 'end']) events.addEventListener(kind, append);
++ async function still() {
++ if (stopped) return;
++ try {
++ const r = await fetch(url('still'), { credentials: 'omit', referrerPolicy: 'no-referrer', cache: 'no-store' });
++ if (r.status === 403) return stop(true);
++ if (!r.ok) { desk.hidden = true; return; }
++ const blob = await r.blob();
++ if (stopped) return;
++ if (picture) URL.revokeObjectURL(picture);
++ picture = URL.createObjectURL(blob); desk.src = picture; desk.hidden = false;
++ } catch { /* the next frame can recover */ }
++ }
++ const poll = setInterval(still, 3000);
++ still();
++ setTimeout(() => stop(true), Math.max(0, Date.parse(document.body.dataset.expires) - Date.now()));
++ window.addEventListener('pagehide', () => stop(false));
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 704edcc..a3245de 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -203,7 +203,11 @@ async function createApp(opts = {}) {
}
if (Object.keys(kept).some((k) => METHODS.has(k))) paths[p] = kept;
}
-- res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json({ ...doc, paths });
++ // 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 });
});
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
@@@ -246,6 +250,8 @@ async function createApp(opts = {}) {
crossOriginResourcePolicy: { policy: 'same-site' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
++ const taskView = require('./task-view/routes').createTaskView(ctx, { fetchImpl });
++ app.use(taskView.embed); // Cookie-free: before cookie parsing and SSO.
app.use(cookieParser());
// ── Machine endpoints ───────────────────────────────────
@@@ -313,6 +319,7 @@ 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));
ddiff --git a/server/task-view/frame-origins.json b/server/task-view/frame-origins.json
nnew file mode 100644
iindex 0000000..f96c940
---- /dev/null
++++ b/server/task-view/frame-origins.json
@@@ -0,0 +1,57 @@
++[
++ "https://bench.openvibe.services",
++ "https://connect.openvibe.website",
++ "https://inventory.openvibe.network",
++ "https://open.shoes",
++ "https://openre.stream",
++ "https://openvibe.actor",
++ "https://openvibe.blog",
++ "https://openvibe.bot",
++ "https://openvibe.cam",
++ "https://openvibe.casino",
++ "https://openvibe.chat",
++ "https://openvibe.codes",
++ "https://openvibe.community",
++ "https://openvibe.coupons",
++ "https://openvibe.deals",
++ "https://openvibe.design",
++ "https://openvibe.domains",
++ "https://openvibe.download",
++ "https://openvibe.email",
++ "https://openvibe.events",
++ "https://openvibe.exchange",
++ "https://openvibe.food",
++ "https://openvibe.fun",
++ "https://openvibe.games",
++ "https://openvibe.help",
++ "https://openvibe.homes",
++ "https://openvibe.host",
++ "https://openvibe.live",
++ "https://openvibe.media",
++ "https://openvibe.meme",
++ "https://openvibe.network",
++ "https://openvibe.news",
++ "https://openvibe.pics",
++ "https://openvibe.quest",
++ "https://openvibe.rent",
++ "https://openvibe.report",
++ "https://openvibe.reviews",
++ "https://openvibe.run",
++ "https://openvibe.services",
++ "https://openvibe.space",
++ "https://openvibe.surf",
++ "https://openvibe.tips",
++ "https://openvibe.today",
++ "https://openvibe.tools",
++ "https://openvibe.tours",
++ "https://openvibe.trade",
++ "https://openvibe.video",
++ "https://openvibe.vip",
++ "https://openvibe.vision",
++ "https://openvibe.watch",
++ "https://openvibe.wiki",
++ "https://openvibe.work",
++ "https://openvibe.zone",
++ "https://play.openvibe.network",
++ "https://search.openvibe.network"
++]
ddiff --git a/server/task-view/frames.js b/server/task-view/frames.js
nnew file mode 100644
iindex 0000000..7bf46f1
---- /dev/null
++++ b/server/task-view/frames.js
@@@ -0,0 +1,25 @@
++'use strict';
++const fallback = require('./frame-origins.json');
++function createFrames({ fetchImpl = globalThis.fetch, networkUrl }) {
++ let origins = fallback, until = 0, pending;
++ return async function get() {
++ if (Date.now() < until) return origins;
++ if (!pending) pending = (async () => {
++ until = Date.now() + 600_000;
++ try {
++ const r = await fetchImpl(`${networkUrl}/api/frame?host=openvibe.actor`, { signal: AbortSignal.timeout(2000), redirect: 'error' });
++ if (!r.ok) return;
++ const data = await r.json();
++ const next = [...(data.nav || []), ...(data.soon || [])].map(s => {
++ const u = new URL(s.url);
++ return u.protocol === 'https:' && !u.username && !u.password && !u.port ? u.origin : null;
++ }).filter(Boolean);
++ if (next.length) origins = [...new Set([...fallback, ...next])];
++ } catch { /* keep the checked-in first-party list */ }
++ finally { pending = null; }
++ })();
++ await pending;
++ return origins;
++ };
++}
++module.exports = { createFrames };
ddiff --git a/server/task-view/openapi.json b/server/task-view/openapi.json
nnew file mode 100644
iindex 0000000..7b0daa0
---- /dev/null
++++ b/server/task-view/openapi.json
@@@ -0,0 +1,79 @@
++{
++ "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"
++ }
++ }
++ }
++}
ddiff --git a/server/task-view/routes.js b/server/task-view/routes.js
nnew file mode 100644
iindex 0000000..2f8d7e5
---- /dev/null
++++ b/server/task-view/routes.js
@@@ -0,0 +1,80 @@
++'use strict';
++const express = require('express');
++const tasks = require('../tasks/store');
++const tickets = require('./tickets');
++const { createFrames } = require('./frames');
++const { html, render } = require('../render/html');
++
++function createTaskView(ctx, { fetchImpl } = {}) {
++ const { s, principal, 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); }
++ });
++ embed.use('/embed/task', (_req, res, next) => {
++ res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex' });
++ next();
++ });
++ embed.use('/embed/task', require('express-rate-limit')({ windowMs: 60_000, limit: 240, standardHeaders: true, legacyHeaders: false }));
++ embed.use('/embed/task/:id', async (req, res, next) => {
++ res.removeHeader('X-Frame-Options');
++ res.set('Content-Security-Policy', `default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' blob:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self' ${(await origins()).join(' ')}`);
++ try {
++ const ticket = await tickets.find(s, req.query.t, req.params.id);
++ const row = ticket && await tasks.getTask(s, req.params.id);
++ if (!row || row.requester !== ticket.requester) return res.status(403).type('html').send('<!doctype html><html lang="en"><title>Expired view</title><main><p>This view has expired</p></main></html>');
++ req.taskView = { row, ticket };
++ next();
++ } catch { log.warn('[Actor] task view read failed', req.params.id); res.sendStatus(503); }
++ });
++ embed.get('/embed/task/:id', (req, res) => {
++ const { row, ticket } = req.taskView;
++ res.type('html').send(render(html`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Task in Actor</title><link rel="stylesheet" href="/css/task-view.css"><script src="/js/task-view.js" defer></script></head><body data-expires="${new Date(ticket.expires_at).toISOString()}"><main><header><h1>${row.task}</h1><p id="state">${row.state}</p></header><img id="desk" hidden alt="The task's live computer screen"><ol id="steps" aria-live="polite"></ol><a href="/tasks/${row.id}" target="_top">Open in Actor</a></main></body></html>`));
++ });
++ embed.get('/embed/task/:id/events', async (req, res) => {
++ const { row, ticket } = req.taskView;
++ const timer = setTimeout(() => res.end(), Math.max(1, new Date(ticket.expires_at).getTime() - s.now()));
++ timer.unref();
++ res.on('close', () => clearTimeout(timer));
++ const after = Math.max(0, parseInt(req.get('last-event-id') || req.query.after, 10) || 0);
++ try {
++ await stream.serve(req, res, { taskId: row.id, after,
++ readBacklog: () => tasks.eventsAfter(s, row.id, after),
++ isEnded: async () => { const cur = await tasks.getTask(s, row.id); return !cur || !tasks.OPEN.includes(cur.state); },
++ });
++ } catch { log.warn('[Actor] task view stream failed', row.id); res.end(); }
++ });
++ embed.get('/embed/task/:id/still', async (req, res) => {
++ const { row, ticket } = req.taskView;
++ try {
++ const computer = await tasks.taskComputer(s, row);
++ const subject = row.requester.slice(5);
++ let shot;
++ if (computer?.session_id) shot = await computers.still(subject, computer.session_id, { signal: AbortSignal.timeout(5000) });
++ else {
++ const desk = await tasks.taskDesk(s, row);
++ const place = desk && tasks.deskPlace(desk.url);
++ if (!place) return res.sendStatus(404);
++ const screen = await computers.screen(subject, place.machine, { desk: place.desk, signal: AbortSignal.timeout(5000) });
++ if (screen && !screen.error && ['jpeg', 'png'].includes(screen.format)) shot = { type: `image/${screen.format}`, bytes: Buffer.from(screen.content_b64, 'base64') };
++ }
++ if (new Date(ticket.expires_at).getTime() <= s.now()) return res.sendStatus(403);
++ if (!shot || shot.error || !['image/jpeg', 'image/png', 'image/webp'].includes(shot.type)) return res.sendStatus(502);
++ res.type(shot.type).end(shot.bytes);
++ } catch { log.warn('[Actor] task view still failed', row.id); res.sendStatus(503); }
++ });
++ // 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 };
++}
++module.exports = { createTaskView };
ddiff --git a/server/task-view/tickets.js b/server/task-view/tickets.js
nnew file mode 100644
iindex 0000000..7670bb7
---- /dev/null
++++ b/server/task-view/tickets.js
@@@ -0,0 +1,16 @@
++'use strict';
++const { randomBytes, createHash } = require('crypto');
++const TTL = 15 * 60_000;
++const hash = (v) => createHash('sha256').update(v).digest('hex');
++async function issue(s, row) {
++ const ticket = `tvt_${randomBytes(32).toString('base64url')}`;
++ const expires_at = new Date(s.now() + TTL).toISOString();
++ await s.db.query('DELETE FROM task_view_tickets WHERE expires_at <= $1', [s.iso()]);
++ await s.db.query('INSERT INTO task_view_tickets (token_hash, task_id, requester, expires_at) VALUES ($1, $2, $3, $4)', [hash(ticket), row.id, row.requester, expires_at]);
++ return { ticket, expires_at };
++}
++async function find(s, raw, id) {
++ if (typeof raw !== 'string' || !/^tvt_[A-Za-z0-9_-]{43}$/.test(raw)) return null;
++ return s.db.maybe('SELECT * FROM task_view_tickets WHERE token_hash = $1 AND task_id = $2 AND expires_at > $3', [hash(raw), id, s.iso()]);
++}
++module.exports = { issue, find, TTL };
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 23ea021..3c0ee52 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -103,6 +103,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
await check('ratchet: every file that makes an outbound request itself is reviewed', () => {
const REVIEWED = {
++ 'server/task-view/frames.js': 'only the configured Network base plus the fixed /api/frame?host=openvibe.actor path; no caller input, no credentials, redirects rejected and a two-second timeout; cached first-party frame origins with a checked-in fallback (test/task-view.test.js)',
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS from the configured Network',
'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
ddiff --git a/test/task-view.test.js b/test/task-view.test.js
nnew file mode 100644
iindex 0000000..6020510
---- /dev/null
++++ b/test/task-view.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const nginx = fs.readFileSync(path.join(__dirname, '../deploy/nginx/openvibe.actor.conf'), 'utf8');
++assert.match(nginx, /log_format ovactor_safe '\$remote_addr \$request_method \$uri \$status'/);
++assert.match(nginx, /location \^~ \/embed\/task\/ \{\s+error_log \/dev\/null crit;/);
++const { boot } = require('./helpers/boot');
++const tasks = require('../server/tasks/store');
++const { createFrames } = require('../server/task-view/frames');
++(async () => {
++ let now = Date.now();
++ const t = await boot({ now: () => now, bot: { computers: { still: async () => ({ type: 'image/png', bytes: Buffer.from('picture') }) } } });
++ try {
++ const me = t.network.addUser('viewer'), other = t.network.addUser('other');
++ const bearer = t.network.userToken(me);
++ async function task(user) {
++ return tasks.insertTask(t.ctx.s, { id: t.ctx.s.newId('tsk'), requester: `user:${user.subject}`, task: '<script>private task</script>', mode: 'balanced', budget_task: 1, budget_day: 5, created_at: t.ctx.s.iso() });
++ }
++ const row = await task(me), second = await task(me), theirs = await task(other);
++ 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 issued = await mint(row.id);
++ assert.equal(issued.status, 200);
++ const u = new URL(issued.json().url), raw = u.searchParams.get('t');
++ assert.match(raw, /^tvt_[A-Za-z0-9_-]{43}$/);
++ assert.equal(Date.parse(issued.json().expires_at) - now, 900000);
++ const p = u.pathname + u.search;
++ for (let i = 0; i < 2; i++) {
++ const page = await t.get(p, { cookie: 'actor_at=invalid-cookie' });
++ assert.equal(page.status, 200); assert(!page.text.includes(raw));
++ assert(page.text.includes('&lt;script&gt;')); assert(page.text.includes('target="_top"'));
++ assert.equal(page.headers.get('cache-control'), 'no-store');
++ assert.equal(page.headers.get('referrer-policy'), 'no-referrer');
++ assert.equal(page.headers.get('x-frame-options'), null);
++ assert.match(page.headers.get('content-security-policy'), /frame-ancestors 'self'.*https:\/\/openvibe.network/);
++ assert(!page.text.includes('navbar'));
++ }
++ assert.match((await t.get('/')).headers.get('content-security-policy'), /frame-ancestors 'none'/);
++ assert.equal((await t.get(p.replace(row.id, second.id))).status, 403);
++ assert.equal((await t.get(`/embed/task/${row.id}`)).status, 403);
++ for (const action of ['cancel', 'takeover', 'giveback', 'view-ticket']) {
++ assert((await t.get(`/api/v1/tasks/${row.id}/${action}`, { bearer: raw, method: 'POST' })).status >= 400);
++ }
++ assert.equal((await t.get(p, { method: 'POST' })).status, 405);
++ await tasks.appendEvent(t.ctx.s, row.id, { kind: 'output', step: 'text', text: 'Working', at: t.ctx.s.iso() });
++ await tasks.appendEvent(t.ctx.s, row.id, { kind: 'end', state: 'succeeded', at: t.ctx.s.iso() });
++ await tasks.updateTask(t.ctx.s, row.id, { state: 'succeeded' });
++ const events = await t.get(`${u.pathname}/events${u.search}`);
++ assert.equal(events.status, 200); assert(events.text.includes('Working')); assert(events.text.includes('event: end'));
++ await tasks.updateTask(t.ctx.s, row.id, { state: 'running' });
++ await tasks.appendEvent(t.ctx.s, row.id, { kind: 'output', step: 'tool_result', tool: 'computer_desk', computer: { session_id: 'session1' }, at: t.ctx.s.iso() });
++ const still = await t.get(`${u.pathname}/still${u.search}`);
++ assert.equal(still.status, 200); assert.equal(still.text, 'picture');
++ assert(!(await t.dbDump()).includes(raw)); assert(!t.logs().includes(raw));
++ now += 900001;
++ for (const suffix of ['', '/events', '/still']) assert.equal((await t.get(`${u.pathname}${suffix}${u.search}`)).status, 403);
++ assert((await t.get(p)).text.includes('This view has expired'));
++ const live = await mint(second.id);
++ const liveUrl = new URL(live.json().url);
++ await t.ctx.s.db.query('UPDATE task_view_tickets SET expires_at = $1 WHERE task_id = $2', [new Date(now + 350).toISOString(), second.id]);
++ const response = await fetch(t.base + liveUrl.pathname + '/events' + liveUrl.search);
++ const event = await tasks.appendEvent(t.ctx.s, second.id, { kind: 'output', step: 'text', text: 'Live step', at: t.ctx.s.iso() });
++ t.ctx.stream.publish(second.id, event);
++ const liveText = await response.text();
++ assert(liveText.includes('Live step'), 'new steps arrive on the existing task stream');
++ assert.equal(t.ctx.stream.status().streams, 0, 'expiry removes the stream subscription');
++ const spec = (await t.get('/openapi.json')).json();
++ assert(spec.paths['/api/v1/tasks/{id}/view-ticket'].post);
++ let calls = 0;
++ const frames = createFrames({ networkUrl: 'https://network.test', fetchImpl: async () => { calls++; return { ok: true, json: async () => ({ nav: [{ url: 'https://openvibe.example/' }], soon: [] }) }; } });
++ assert((await frames()).includes('https://openvibe.example')); await frames(); assert.equal(calls, 1);
++ process.stdout.write('task-view: scope, owner, cookies, CSP, hashing, expiry, events, still and write rejection passed\n');
++ } finally { await t.close(); }
++})().then(() => process.exit(0), e => { process.stderr.write(String(e.stack) + '\n'); process.exit(1); });