openvibers/openvibe.network Commits
The Action Center: Cancel reaches the task that offered it (#446)
Changes
@@@ -4,7 +4,7 @@* by the ✦ beside the bell on every OpenVibe site with the person's Network token.** GET /api/v1/me/actions network.me-actions-result@1-- * POST /api/v1/me/actions/:service/:id/:action approve | decline → { item } (common.action-item@1), or a problem++ * POST /api/v1/me/actions/:service/:id/:action approve | decline | cancel → { item } (common.action-item@1), or a problem** Sources, asked in parallel for the person's subject:* network their pending confirmations (server/developer/confirmations.js): waiting, approve or decline here;@@@ -29,6 +29,11 @@ const DECIDE = {actor: { cap: 'actor.approval.decide', path: (id) => `/api/v1/approvals/${encodeURIComponent(id)}/decide`, name: 'OpenVibe.Actor', what: 'approval' },domains: { cap: 'domains.proposal.decide', path: (id) => `/api/v1/proposals/${encodeURIComponent(id)}/decide`, name: 'OpenVibe.Domains', what: 'proposal' },};++// Who can cancel from the Action Center, and how: the capability Network signs for, and the route. The Dock's++// Cancel button was dead before this: the item offered it (common.action-item@1) and the rail refused it.++const CANCEL = {++ actor: { cap: 'actor.task.create', path: (id) => '/api/v1/tasks/' + encodeURIComponent(id) + '/cancel', name: 'OpenVibe.Actor', what: 'task' },++};const ACTIONS_TIMEOUT_MS = 2500;const CACHE_MS = 10_000;const CACHE_MAX = 500;@@@ -113,10 +118,11 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...return doc;}-- /** Approve or decline one waiting item through its authority. → common.action-item@1 */++ /** Approve, decline or cancel one item through its authority. → common.action-item@1 */async function act(actor, service, id, action, ctx) {if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such item'), { status: 404, code: 'actions.not_found' });-- if (action !== 'approve' && action !== 'decline') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });++ if (action !== 'approve' && action !== 'decline' && action !== 'cancel') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });++ if (action === 'cancel') return await cancel(actor, service, id, ctx);cache.delete(actor.subject);if (service === 'network') {const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });@@@ -137,6 +143,32 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...return body;}++ /**++ * Cancel one running item through its authority. The item leaves the running bucket and the person gets the++ * item as it is now, so the Dock can redraw it without a second read.++ */++ async function cancel(actor, service, id, ctx) {++ const c = CANCEL[service];++ if (!c) throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });++ const token = urls[service] ? selfToken('openvibe.' + service, [c.cap]) : null;++ if (!token) throw Object.assign(new Error(c.name + ' is not connected'), { status: 503, code: 'actions.not_connected' });++ const res = await fetchImpl(String(urls[service]).replace(/\/+$/, '') + c.path(id), {++ method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-ov-subject': actor.subject },++ body: JSON.stringify({}), signal: AbortSignal.timeout(timeoutMs * 2),++ });++ const body = await res.json().catch(() => ({}));++ if (res.status === 404) throw Object.assign(new Error('no such ' + c.what), { status: 404, code: 'actions.not_found' });++ if (!res.ok && res.status !== 202) throw Object.assign(new Error((body && (body.detail || body.title)) || (c.name + ' answered ' + res.status)), { status: 502, code: 'actions.upstream' });++ const doc = await forPerson(actor);++ const item = Object.values(doc.buckets || {}).flat().find((it) => it.service === service && it.id === id);++ return item || {++ service, id, kind: 'agent_task', title: '', summary: null, bucket: 'failed', state: 'cancelled',++ agent: null, cost_usd: null, steps: null, error: null,++ created_at: new Date(now()).toISOString(), updated_at: new Date(now()).toISOString(),++ next_run_at: null, href: null, actions: ['open'],++ };++ }++return { forPerson, act, _cache: cache };}@@@ -168,4 +200,4 @@ function router({ actions, requireAuth, requireDock = () => requireAuth }) {return r;}--module.exports = { createActions, router, confirmationItem, BUCKETS, ACTIONS_TIMEOUT_MS, CACHE_MS };++module.exports = { createActions, router, confirmationItem, BUCKETS, ACTIONS_TIMEOUT_MS, CACHE_MS, CANCEL };@@@ -30,6 +30,7 @@ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_v// Fake Actor (two items, one invalid, one claiming to be Codes) and AI (slow); Codes is not deployed (404).const calls = [];let aiDelay = 0;++let cancelled = false;const svc = http.createServer((req, res) => {let body = '';req.on('data', (c) => { body += c; });@@@ -38,7 +39,7 @@ const svc = http.createServer((req, res) => {calls.push({ method: req.method, path: u.pathname, subject: u.searchParams.get('subject') || req.headers['x-ov-subject'] || null, auth: req.headers.authorization, body: body ? JSON.parse(body) : null });const json = (s, b) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(b)); };if (u.pathname === '/actor/api/v1/actions') return json(200, { items: [-- item(),++ item(cancelled ? { state: 'cancelled', bucket: 'failed', actions: ['open'], updated_at: iso(1) } : {}),item({ id: 'apr_1', kind: 'approval', title: 'Spend $0.40 on a desk hour', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(5), href: 'https://openvibe.actor/me/approvals' }),item({ id: 'tsk_bad', href: 'http://openvibe.actor/tasks/x' }),item({ id: 'tsk_spoof', service: 'codes' }),@@@ -48,6 +49,8 @@ const svc = http.createServer((req, res) => {if (u.pathname === '/ai/api/v1/actions') return setTimeout(() => json(200, { items: [item({ service: 'ai', id: 'run_1', kind: 'ai_run', bucket: 'done', state: 'succeeded', href: 'https://ai.openvibe.services/runs/run_1', actions: ['open'] })] }), aiDelay);if (u.pathname === '/actor/api/v1/approvals/apr_1/decide') return json(200, item({ id: 'apr_1', kind: 'approval', bucket: 'done', state: 'approved', actions: ['open'] }));if (u.pathname === '/actor/api/v1/approvals/apr_404/decide') return json(404, { code: 'not_found' });++ if (u.pathname === '/actor/api/v1/tasks/tsk_1/cancel') { cancelled = true; return json(200, { id: 'tsk_1', state: 'cancelled' }); }++ if (u.pathname === '/actor/api/v1/tasks/tsk_404/cancel') return json(404, { code: 'not_found' });if (u.pathname === '/domains/api/v1/actions') return json(200, { items: [item({ service: 'domains', id: 'dpr_1', kind: 'approval', title: 'Add a TXT record to example.com', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(20), href: 'https://openvibe.domains/panel/p/dpr_1' })] });if (u.pathname === '/domains/api/v1/proposals/dpr_1/decide') return json(200, item({ service: 'domains', id: 'dpr_1', kind: 'approval', bucket: 'done', state: 'applied', actions: ['open'], href: 'https://openvibe.domains/panel/p/dpr_1' }));if (u.pathname === '/domains/api/v1/proposals/dpr_2/decide') return json(409, { code: 'proposal.decided', detail: 'This proposal is applied already.' });@@@ -133,7 +136,16 @@ try {const dd = calls.find((c) => c.path === '/domains/api/v1/proposals/dpr_1/decide');assert.deepStrictEqual([dd.method, dd.auth, dd.body.decision, dd.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.domains:domains.proposal.decide', 'approve', true]);assert.strictEqual((await call('POST', '/domains/dpr_2/approve')).status, 409, 'decided already, in words');-- assert.deepStrictEqual([(await call('POST', '/codes/tsk_1/approve')).body.code, (await call('POST', '/actor/tsk_1/cancel')).body.code], ['actions.unsupported', 'actions.unsupported']);++ // The Dock's Cancel (the item offered it and the rail refused it): it now goes through the same rail as++ // approve and decline, to Actor's own cancel route, with Actor's own capability and this person.++ r = await call('POST', '/actor/tsk_1/cancel');++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ const dc = calls.find((c) => c.path === '/actor/api/v1/tasks/tsk_1/cancel');++ assert.deepStrictEqual([dc.method, dc.auth, dc.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.actor:actor.task.create', true]);++ assert.deepStrictEqual([r.body.item.state, r.body.item.bucket], ['cancelled', 'failed'], 'the item comes back as it is now');++ assert.strictEqual((await call('POST', '/actor/tsk_404/cancel')).status, 404, 'no such task');++ // Codes has no cancel route, and Codes is not connected for it either.++ assert.strictEqual((await call('POST', '/codes/tsk_1/approve')).body.code, 'actions.unsupported');assert.strictEqual((await call('POST', '/actor/..%2Fx/approve')).status, 404, 'ids are plain');} finally {server.close(); svc.close();@@@ -125,7 +125,7 @@ const REVIEWED = {'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],-- 'server/actions/index.js fetch': [3, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed, the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],++ 'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],'server/profile/public.js fetch': [4, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030), OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) and OpenVibe.Play (OV_PLAY_INTERNAL_URL, loopback 5040) only; the paths are fixed, the subject is a checked usr_ id'],'server/profile/avatar.js fetch': [3, 'Live and Media internal URLs from config; verifyImage only for https://openvibe.media/ addresses (normalizeAvatar, asserted below)'],'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* by the ✦ beside the bell on every OpenVibe site with the person's Network token.
* by the ✦ beside the bell on every OpenVibe site with the person's Network token.
*
*
* GET /api/v1/me/actions network.me-actions-result@1
* GET /api/v1/me/actions network.me-actions-result@1
- * POST /api/v1/me/actions/:service/:id/:action approve | decline → { item } (common.action-item@1), or a problem
+ * POST /api/v1/me/actions/:service/:id/:action approve | decline | cancel → { item } (common.action-item@1), or a problem
*
*
* Sources, asked in parallel for the person's subject:
* Sources, asked in parallel for the person's subject:
* network their pending confirmations (server/developer/confirmations.js): waiting, approve or decline here;
* network their pending confirmations (server/developer/confirmations.js): waiting, approve or decline here;
@@ -29,6 +29,11 @@ const DECIDE = {
@@ -29,6 +29,11 @@ const DECIDE = {
actor: { cap: 'actor.approval.decide', path: (id) => `/api/v1/approvals/${encodeURIComponent(id)}/decide`, name: 'OpenVibe.Actor', what: 'approval' },
actor: { cap: 'actor.approval.decide', path: (id) => `/api/v1/approvals/${encodeURIComponent(id)}/decide`, name: 'OpenVibe.Actor', what: 'approval' },
domains: { cap: 'domains.proposal.decide', path: (id) => `/api/v1/proposals/${encodeURIComponent(id)}/decide`, name: 'OpenVibe.Domains', what: 'proposal' },
domains: { cap: 'domains.proposal.decide', path: (id) => `/api/v1/proposals/${encodeURIComponent(id)}/decide`, name: 'OpenVibe.Domains', what: 'proposal' },
};
};
+// Who can cancel from the Action Center, and how: the capability Network signs for, and the route. The Dock's
+// Cancel button was dead before this: the item offered it (common.action-item@1) and the rail refused it.
+const CANCEL = {
+ actor: { cap: 'actor.task.create', path: (id) => '/api/v1/tasks/' + encodeURIComponent(id) + '/cancel', name: 'OpenVibe.Actor', what: 'task' },
+};
const ACTIONS_TIMEOUT_MS = 2500;
const ACTIONS_TIMEOUT_MS = 2500;
const CACHE_MS = 10_000;
const CACHE_MS = 10_000;
const CACHE_MAX = 500;
const CACHE_MAX = 500;
@@ -113,10 +118,11 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
@@ -113,10 +118,11 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
return doc;
return doc;
}
}
- /** Approve or decline one waiting item through its authority. → common.action-item@1 */
+ /** Approve, decline or cancel one item through its authority. → common.action-item@1 */
async function act(actor, service, id, action, ctx) {
async function act(actor, service, id, action, ctx) {
if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such item'), { status: 404, code: 'actions.not_found' });
if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such item'), { status: 404, code: 'actions.not_found' });
- if (action !== 'approve' && action !== 'decline') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
+ if (action !== 'approve' && action !== 'decline' && action !== 'cancel') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
+ if (action === 'cancel') return await cancel(actor, service, id, ctx);
cache.delete(actor.subject);
cache.delete(actor.subject);
if (service === 'network') {
if (service === 'network') {
const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });
const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });
@@ -137,6 +143,32 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
@@ -137,6 +143,32 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
return body;
return body;
}
}
+ /**
+ * Cancel one running item through its authority. The item leaves the running bucket and the person gets the
+ * item as it is now, so the Dock can redraw it without a second read.
+ */
+ async function cancel(actor, service, id, ctx) {
+ const c = CANCEL[service];
+ if (!c) throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
+ const token = urls[service] ? selfToken('openvibe.' + service, [c.cap]) : null;
+ if (!token) throw Object.assign(new Error(c.name + ' is not connected'), { status: 503, code: 'actions.not_connected' });
+ const res = await fetchImpl(String(urls[service]).replace(/\/+$/, '') + c.path(id), {
+ method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-ov-subject': actor.subject },
+ body: JSON.stringify({}), signal: AbortSignal.timeout(timeoutMs * 2),
+ });
+ const body = await res.json().catch(() => ({}));
+ if (res.status === 404) throw Object.assign(new Error('no such ' + c.what), { status: 404, code: 'actions.not_found' });
+ if (!res.ok && res.status !== 202) throw Object.assign(new Error((body && (body.detail || body.title)) || (c.name + ' answered ' + res.status)), { status: 502, code: 'actions.upstream' });
+ const doc = await forPerson(actor);
+ const item = Object.values(doc.buckets || {}).flat().find((it) => it.service === service && it.id === id);
+ return item || {
+ service, id, kind: 'agent_task', title: '', summary: null, bucket: 'failed', state: 'cancelled',
+ agent: null, cost_usd: null, steps: null, error: null,
+ created_at: new Date(now()).toISOString(), updated_at: new Date(now()).toISOString(),
+ next_run_at: null, href: null, actions: ['open'],
+ };
+ }
+
return { forPerson, act, _cache: cache };
return { forPerson, act, _cache: cache };
}
}
@@ -168,4 +200,4 @@ function router({ actions, requireAuth, requireDock = () => requireAuth }) {
@@ -168,4 +200,4 @@ function router({ actions, requireAuth, requireDock = () => requireAuth }) {
return r;
return r;
}
}
-module.exports = { createActions, router, confirmationItem, BUCKETS, ACTIONS_TIMEOUT_MS, CACHE_MS };
+module.exports = { createActions, router, confirmationItem, BUCKETS, ACTIONS_TIMEOUT_MS, CACHE_MS, CANCEL };
@@ -30,6 +30,7 @@ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_v
@@ -30,6 +30,7 @@ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_v
// Fake Actor (two items, one invalid, one claiming to be Codes) and AI (slow); Codes is not deployed (404).
// Fake Actor (two items, one invalid, one claiming to be Codes) and AI (slow); Codes is not deployed (404).
const calls = [];
const calls = [];
let aiDelay = 0;
let aiDelay = 0;
+let cancelled = false;
const svc = http.createServer((req, res) => {
const svc = http.createServer((req, res) => {
let body = '';
let body = '';
req.on('data', (c) => { body += c; });
req.on('data', (c) => { body += c; });
@@ -38,7 +39,7 @@ const svc = http.createServer((req, res) => {
@@ -38,7 +39,7 @@ const svc = http.createServer((req, res) => {
calls.push({ method: req.method, path: u.pathname, subject: u.searchParams.get('subject') || req.headers['x-ov-subject'] || null, auth: req.headers.authorization, body: body ? JSON.parse(body) : null });
calls.push({ method: req.method, path: u.pathname, subject: u.searchParams.get('subject') || req.headers['x-ov-subject'] || null, auth: req.headers.authorization, body: body ? JSON.parse(body) : null });
const json = (s, b) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(b)); };
const json = (s, b) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(b)); };
if (u.pathname === '/actor/api/v1/actions') return json(200, { items: [
if (u.pathname === '/actor/api/v1/actions') return json(200, { items: [
- item(),
+ item(cancelled ? { state: 'cancelled', bucket: 'failed', actions: ['open'], updated_at: iso(1) } : {}),
item({ id: 'apr_1', kind: 'approval', title: 'Spend $0.40 on a desk hour', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(5), href: 'https://openvibe.actor/me/approvals' }),
item({ id: 'apr_1', kind: 'approval', title: 'Spend $0.40 on a desk hour', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(5), href: 'https://openvibe.actor/me/approvals' }),
item({ id: 'tsk_bad', href: 'http://openvibe.actor/tasks/x' }),
item({ id: 'tsk_bad', href: 'http://openvibe.actor/tasks/x' }),
item({ id: 'tsk_spoof', service: 'codes' }),
item({ id: 'tsk_spoof', service: 'codes' }),
@@ -48,6 +49,8 @@ const svc = http.createServer((req, res) => {
@@ -48,6 +49,8 @@ const svc = http.createServer((req, res) => {
if (u.pathname === '/ai/api/v1/actions') return setTimeout(() => json(200, { items: [item({ service: 'ai', id: 'run_1', kind: 'ai_run', bucket: 'done', state: 'succeeded', href: 'https://ai.openvibe.services/runs/run_1', actions: ['open'] })] }), aiDelay);
if (u.pathname === '/ai/api/v1/actions') return setTimeout(() => json(200, { items: [item({ service: 'ai', id: 'run_1', kind: 'ai_run', bucket: 'done', state: 'succeeded', href: 'https://ai.openvibe.services/runs/run_1', actions: ['open'] })] }), aiDelay);
if (u.pathname === '/actor/api/v1/approvals/apr_1/decide') return json(200, item({ id: 'apr_1', kind: 'approval', bucket: 'done', state: 'approved', actions: ['open'] }));
if (u.pathname === '/actor/api/v1/approvals/apr_1/decide') return json(200, item({ id: 'apr_1', kind: 'approval', bucket: 'done', state: 'approved', actions: ['open'] }));
if (u.pathname === '/actor/api/v1/approvals/apr_404/decide') return json(404, { code: 'not_found' });
if (u.pathname === '/actor/api/v1/approvals/apr_404/decide') return json(404, { code: 'not_found' });
+ if (u.pathname === '/actor/api/v1/tasks/tsk_1/cancel') { cancelled = true; return json(200, { id: 'tsk_1', state: 'cancelled' }); }
+ if (u.pathname === '/actor/api/v1/tasks/tsk_404/cancel') return json(404, { code: 'not_found' });
if (u.pathname === '/domains/api/v1/actions') return json(200, { items: [item({ service: 'domains', id: 'dpr_1', kind: 'approval', title: 'Add a TXT record to example.com', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(20), href: 'https://openvibe.domains/panel/p/dpr_1' })] });
if (u.pathname === '/domains/api/v1/actions') return json(200, { items: [item({ service: 'domains', id: 'dpr_1', kind: 'approval', title: 'Add a TXT record to example.com', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(20), href: 'https://openvibe.domains/panel/p/dpr_1' })] });
if (u.pathname === '/domains/api/v1/proposals/dpr_1/decide') return json(200, item({ service: 'domains', id: 'dpr_1', kind: 'approval', bucket: 'done', state: 'applied', actions: ['open'], href: 'https://openvibe.domains/panel/p/dpr_1' }));
if (u.pathname === '/domains/api/v1/proposals/dpr_1/decide') return json(200, item({ service: 'domains', id: 'dpr_1', kind: 'approval', bucket: 'done', state: 'applied', actions: ['open'], href: 'https://openvibe.domains/panel/p/dpr_1' }));
if (u.pathname === '/domains/api/v1/proposals/dpr_2/decide') return json(409, { code: 'proposal.decided', detail: 'This proposal is applied already.' });
if (u.pathname === '/domains/api/v1/proposals/dpr_2/decide') return json(409, { code: 'proposal.decided', detail: 'This proposal is applied already.' });
@@ -133,7 +136,16 @@ try {
@@ -133,7 +136,16 @@ try {
const dd = calls.find((c) => c.path === '/domains/api/v1/proposals/dpr_1/decide');
const dd = calls.find((c) => c.path === '/domains/api/v1/proposals/dpr_1/decide');
assert.deepStrictEqual([dd.method, dd.auth, dd.body.decision, dd.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.domains:domains.proposal.decide', 'approve', true]);
assert.deepStrictEqual([dd.method, dd.auth, dd.body.decision, dd.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.domains:domains.proposal.decide', 'approve', true]);
assert.strictEqual((await call('POST', '/domains/dpr_2/approve')).status, 409, 'decided already, in words');
assert.strictEqual((await call('POST', '/domains/dpr_2/approve')).status, 409, 'decided already, in words');
- assert.deepStrictEqual([(await call('POST', '/codes/tsk_1/approve')).body.code, (await call('POST', '/actor/tsk_1/cancel')).body.code], ['actions.unsupported', 'actions.unsupported']);
+ // The Dock's Cancel (the item offered it and the rail refused it): it now goes through the same rail as
+ // approve and decline, to Actor's own cancel route, with Actor's own capability and this person.
+ r = await call('POST', '/actor/tsk_1/cancel');
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ const dc = calls.find((c) => c.path === '/actor/api/v1/tasks/tsk_1/cancel');
+ assert.deepStrictEqual([dc.method, dc.auth, dc.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.actor:actor.task.create', true]);
+ assert.deepStrictEqual([r.body.item.state, r.body.item.bucket], ['cancelled', 'failed'], 'the item comes back as it is now');
+ assert.strictEqual((await call('POST', '/actor/tsk_404/cancel')).status, 404, 'no such task');
+ // Codes has no cancel route, and Codes is not connected for it either.
+ assert.strictEqual((await call('POST', '/codes/tsk_1/approve')).body.code, 'actions.unsupported');
assert.strictEqual((await call('POST', '/actor/..%2Fx/approve')).status, 404, 'ids are plain');
assert.strictEqual((await call('POST', '/actor/..%2Fx/approve')).status, 404, 'ids are plain');
} finally {
} finally {
server.close(); svc.close();
server.close(); svc.close();
@@ -125,7 +125,7 @@ const REVIEWED = {
@@ -125,7 +125,7 @@ const REVIEWED = {
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
- 'server/actions/index.js fetch': [3, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed, the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
+ 'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/profile/public.js fetch': [4, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030), OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) and OpenVibe.Play (OV_PLAY_INTERNAL_URL, loopback 5040) only; the paths are fixed, the subject is a checked usr_ id'],
'server/profile/public.js fetch': [4, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030), OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) and OpenVibe.Play (OV_PLAY_INTERNAL_URL, loopback 5040) only; the paths are fixed, the subject is a checked usr_ id'],
'server/profile/avatar.js fetch': [3, 'Live and Media internal URLs from config; verifyImage only for https://openvibe.media/ addresses (normalizeAvatar, asserted below)'],
'server/profile/avatar.js fetch': [3, 'Live and Media internal URLs from config; verifyImage only for https://openvibe.media/ addresses (normalizeAvatar, asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],