openvibers/openvibe.services Commits
Services: act on your own resources — stop/resume a robot, archive a room, suspend/resume a stream, delete a paste (contracts 0.176.0) (#61)
Changes
nnew file mode 100644@@@ -0,0 +1,4 @@++-- phase: expand++-- Person-owned resources (ADR-048 amendment 2026-10-15): a control request for a person's robot, room, restream++-- definition or paste names no project, so its audit row has none either. Old code always writes one.++ALTER TABLE services_audit ALTER COLUMN project_id DROP NOT NULL;@@@ -16,7 +16,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.173.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0"@@@ -890,9 +890,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.173.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.173.0",-- "integrity": "sha512-6zHbU9zghIYDd3PPGtgMTsIgxrWwsoBHO/rvuLW1rghr6kRK6VwCmSMZRcjQbcPODPH1XrUAFuLg/T1K4nOCMQ==",++ "version": "0.176.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",++ "integrity": "sha512-COLEoRbjs6he+Tb2LvazezNWvHzB6CEWflebrgEyMaTd1hhE5zWJAAL/mdvR0LOzVDAX6vb1GPdTw83AulLfCg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,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.173.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0"@@@ -98,7 +98,8 @@ function v1Router({ config, apiAuth, index, authorities, control }) {throw new ServiceError(400, 'control.bad_request', 'the body is not a valid common.resource-control-request@1',{ errors: check.errors.slice(0, 20).map((e) => ({ path: String(e.path || e.instancePath || ''), message: String(e.message || 'invalid') })) });}-- await apiAuth.authorizeControl(req, request.project_id);++ // A project's resource: the caller must control that project. A person's own resource: only its owner (control.run).++ if (!request.owned) await apiAuth.authorizeControl(req, request.project_id);const result = await control.run(request, req.principal);res.status(httpStatusOf(result)).json(result);});@@@ -48,7 +48,7 @@ function canonical(v) {const digestOf = (v) => crypto.createHash('sha256').update(canonical(v)).digest('hex');/** What the request asks for, without its delivery fields (idempotency_key, confirmation_id, trace_id). */--const ACTION_FIELDS = ['action', 'resource', 'resource_kind', 'project_id', 'params', 'on_behalf_of', 'dry_run'];++const ACTION_FIELDS = ['action', 'resource', 'resource_kind', 'owned', 'owner', 'project_id', 'params', 'on_behalf_of', 'dry_run'];const actionOf = (request) => Object.fromEntries(ACTION_FIELDS.filter((k) => request[k] !== undefined).map((k) => [k, request[k]]));const actorOf = (principal) => (principal.kind === 'user' ? `user:${principal.subject}` : principal.sub);@@@ -113,13 +113,23 @@ function createControl({ store, index, confirmations, log = console }) {}onBehalfOf = { type: 'user', id: principal.subject };}++ // A person-owned resource (ADR-048 amendment 2026-10-15) is controlled by its owner: their own user token, or a++ // first-party service acting for them. Services cannot tell whose an agent is, so agents are not offered these.++ if (request.owned) {++ if (onBehalfOf && onBehalfOf.type !== 'user') {++ throw new ServiceError(403, 'control.person_only', 'a person-owned resource is controlled by its owner; agents cannot act on them yet');++ }++ if (!onBehalfOf || onBehalfOf.id !== request.owner.id) {++ throw new ServiceError(403, 'capability.denied', 'only its owner controls a person-owned resource');++ }++ }const forward = { ...request };delete forward.confirmation_id;if (onBehalfOf) forward.on_behalf_of = onBehalfOf;const action = actionOf(forward);const digest = digestOf(action);-- const project = request.project_id;-- const target = request.resource || `kind:${request.resource_kind}`;++ const project = request.project_id || null;++ const target = request.resource || (request.owned ? `${request.owned.kind}/${request.owned.id}` : `kind:${request.resource_kind}`);const key = request.idempotency_key;// 1) The idempotency key: claim it, replay its answer, or refuse.@@@ -57,6 +57,16 @@ const KIND_ACTIONS = {default: { actions: (x) => [x.state === 'paused' ? ['resume', 'Resume'] : ['suspend', 'Suspend'], ['delete', 'Delete…']] },};++// What a person may do to their own resources outside any project (ADR-048 amendment 2026-10-15), by kind: the++// authority's <service>.resource.control lists the same actions and checks that they are the owner.++const OWNED_ACTIONS = {++ 'bot.robot': (x) => (x.state === 'estopped' ? [['resume', 'Resume']] : [['stop', 'Stop']]),++ 'chat.room': () => [['archive', 'Archive…']],++ 'openre.stream': (x) => (x.state === 'disabled' ? [['resume', 'Resume']] : [['suspend', 'Suspend']]),++ 'community.paste': () => [['delete', 'Delete…']],++};++const OWNED_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;++const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;const CRD_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -173,11 +183,50 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((pheading: html`<h1>Your resources</h1>`,intro: 'What you own yourself, outside any project: your robots, chat rooms, streams, watches, Actor tasks, pastes, forum spaces and the rest. Each OpenVibe service answers for its own, read just now and merged here; the service that holds each one owns it.',empty: 'Nothing of yours outside a project yet. A robot you add on OpenVibe.Bot, a room you open on OpenVibe.Chat, a stream on OpenRestream, a watch on OpenVibe.Watch or a paste on OpenVibe.Community appears here.',-- footer: html`Resources in a project are on that project's page: <a href="/projects">your projects</a>.`,++ footer: html`Resources in a project are on that project's page: <a href="/projects">your projects</a>. Stop or resume a robot, archive a chat room, suspend or resume a stream and delete a paste here: the service that holds each one does it, after checking it is yours.`,ownerColumn: false,++ controls: { owned: true },});});++ // One control operation on your own resource (ADR-048 amendment 2026-10-15), through server/domain/control.js like++ // every other: the service that holds it checks you are its owner and decides. Archive and delete ask first.++ const OWNED_DONE = { stop: 'Stopped', resume: 'Resumed', suspend: 'Suspended', archive: 'Archived', delete: 'Deleted' };++ r.post('/mine/resources/control', B('services.resource.control'), form, guard, async (req, res) => {++ const back = '/projects/mine';++ const b = req.body || {};++ const action = String(b.action || '');++ const kind = String(b.kind || '');++ const id = String(b.id || '');++ const notDone = (detail, status = 422) => page(req, res, { title: 'Not done', body: html`<h1>Not done</h1><p>${detail}</p><p><a href="${back}">Back</a></p>` }, status);++ if (!OWNED_ACTIONS[kind] || !OWNED_ID_RE.test(id)) return notDone('That is not one of your resources that can be acted on here.');++ if (!OWNED_DONE[action]) return notDone('Stop, resume, suspend, archive or delete.');++ if ((action === 'delete' || action === 'archive') && b.confirm !== 'yes') {++ const what = action === 'delete' ? 'Delete' : 'Archive';++ return page(req, res, {++ title: `${what}?`, crumbs: [{ label: 'Projects', href: '/projects' }, { label: 'Your resources', href: back }, { label: what }],++ body: html`<h1>${what} this?</h1><p><code>${kind}</code> <code>${id}</code></p><p>${action === 'delete' ? 'The service that holds it deletes it. This cannot be undone here.' : 'The room leaves every listing and takes no new messages; its history stays.'}</p>++<form method="post" action="/projects/mine/resources/control" class="inline-form">${csrfField(csrf(req))}<input type="hidden" name="kind" value="${kind}"><input type="hidden" name="id" value="${id}"><input type="hidden" name="action" value="${action}"><input type="hidden" name="confirm" value="yes"><button type="submit">${what} it</button></form> <a href="${back}">Cancel</a>`,++ });++ }++ const me = { type: 'user', id: req.viewer.subject };++ const request = { action, owned: { kind, id }, owner: me, on_behalf_of: me, idempotency_key: `portal-${crypto.randomBytes(12).toString('hex')}` };++ let result;++ try {++ const check = require('openvibe-contracts').resources.checkControlRequest(request);++ if (!check.valid) throw new ServiceError(400, 'control.bad_request', 'that is not one of your resources');++ result = await ctx.control.run(request, { kind: 'user', subject: req.viewer.subject });++ } catch (err) {++ if (!(err instanceof ServiceError)) throw err;++ return notDone(html`${err.detail || err.message} (<code>${err.code}</code>)`, err.status || 400);++ }++ if (result.state === 'done' || result.state === 'pending') {++ return res.redirect(303, `${back}?done=${encodeURIComponent(`${OWNED_DONE[action]}${result.state === 'pending' ? ' (the service finishes it shortly)' : ''}: ${kind} ${id}`)}`);++ }++ const why = result.problem ? `${result.problem.detail || result.problem.title || ''} (${result.problem.code || result.problem.status})` : result.state;++ return page(req, res, { title: 'Not done', body: html`<h1>The service did not do it</h1><p>${why}</p><p><a href="${back}">Back to your resources</a></p>` }, 200);++ });++r.get('/:project', idParams, async (req, res) => {const project = await loadProject(req, res);if (!project) return;@@@ -411,6 +460,13 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$// lets a project's admins act (KIND_ACTIONS `admins`: what its apps made). The service decides either way.const none = html`<span class="muted">—</span>`;const actionsCell = (x) => {++ if (controls && controls.owned) {++ // Your own resources: only what you own, at a service that takes owned control requests.++ const offer = OWNED_ACTIONS[x.kind];++ if (!offer || x.ovrn || !controllable.has(x.service) || !(x.owner && x.owner.type === 'user' && x.owner.id === me)) return none;++ const act = ([action, label]) => html`<form method="post" action="/projects/mine/resources/control" class="inline-form">${csrfField(c)}<input type="hidden" name="kind" value="${x.kind}"><input type="hidden" name="id" value="${x.id}"><input type="hidden" name="action" value="${action}"><button type="submit">${label}</button></form>`;++ return html`${offer(x).map((o, i) => html`${i ? ' ' : ''}${act(o)}`)}`;++ }if (!controls || !x.ovrn || !controllable.has(x.service)) return none;const kind = KIND_ACTIONS[x.kind] || KIND_ACTIONS.default;const mine = !!(x.owner && x.owner.type === 'user' && x.owner.id === me);@@@ -174,7 +174,9 @@ function createResourceIndexService({ config, authorities, tokens, self = null,* { service, adapter }, or ServiceError 400 resources.unknown_service.*/function controlOwner(request) {-- const service = request.resource ? parseOvrn(request.resource).service : String(request.resource_kind || '').split('.')[0];++ // A project's resource by its OVRN or kind; a person-owned one by its index kind (ADR-048 amendment 2026-10-15).++ const service = request.resource ? parseOvrn(request.resource).service++ : String((request.owned && request.owned.kind) || request.resource_kind || '').split('.')[0];const adapter = adapterById.get(service);if (!adapter) throw new ServiceError(400, 'resources.unknown_service', `${service} is not an authority Services reads`);return { service, adapter };@@@ -201,6 +203,7 @@ function createResourceIndexService({ config, authorities, tokens, self = null,if (answer === null) return unsupported();const bad = (detail) => controlResult(request, 'failed', { problem: { status: 502, code: 'resources.authority_bad_response', detail } });if (!contracts.resources.checkControlResult(answer).valid) return bad(`${service} answered something that is not a common.resource-control-result@1`);++ if (answer.resource && request.owned) return bad(`${service} answered with a resource name for a person-owned resource`);if (answer.resource) {const named = contracts.resources.parse(answer.resource);if (!named || named.project_id !== request.project_id || named.service !== service) return bad(`${service} answered for a resource outside ${request.project_id}`);nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++// ADR-048 amendment 2026-10-15: a person controls their own resources outside any project (a robot here) through++// Services. A common.owned-resource-control-request@1 goes to the authority as Services, with the authority's own++// capability, only for its owner (their user token, or a first-party service acting for them; never an agent yet);++// the audit row names no project; "Your resources" offers the actions and asks before destructive ones.++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const { serviceAuth, ids, resources } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++/** A Bot authority: its index answers `listed`; its control checks the token and the owner it holds. */++async function startBot() {++ const seen = [];++ let network = null;++ let listed = [];++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };++ if (req.method === 'GET' && req.url.startsWith('/api/v1/resources')) return send(200, { resources: listed, next_cursor: null });++ if (req.url !== '/api/v1/resources/control' || req.method !== 'POST') return send(404, { type: 'about:blank', title: 'Not found', status: 404, code: 'route.not_found' });++ const token = String(req.headers.authorization || '').replace(/^Bearer /, '');++ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.bot' });++ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));++ seen.push({ body, claims: v.ok ? v.claims : null });++ if (!v.ok || !(v.claims.cap || []).includes('bot.resource.control')) return send(403, { type: 'about:blank', title: 'Forbidden', status: 403, code: 'capability.denied' });++ const at = new Date().toISOString();++ const row = listed.find((x) => x.id === body.owned.id);++ if (!row || !row.owner || row.owner.id !== body.owner.id) {++ return send(200, { action: body.action, state: 'refused', problem: { type: 'about:blank', title: 'resources.unknown_resource', status: 404, code: 'resources.unknown_resource', detail: 'no robot of this person' }, at });++ }++ if (row.name === 'Talks back') return send(200, { action: body.action, resource: resources.format({ service: 'bot', project_id: ids.newId('project'), type: 'robot', id: row.id }), state: 'done', at });++ return send(200, { action: body.action, state: 'done', result: { estop: body.action === 'stop', changed: true }, at });++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return {++ url: `http://127.0.0.1:${server.address().port}`, seen,++ bind(n) { network = n; },++ list(rows) { listed = rows; },++ close: () => new Promise((r) => server.close(r)),++ };++}++++const authority = (id, origin, control) => Object.freeze({++ id, name: id, internalOrigin: origin, publicOrigin: null, audience: `openvibe.${id}`, capability: `${id}.resource.read`,++ control, manifestVersion: '0.1.0', contractsRange: null, status: 'alpha',++});++function registryOf(list) {++ const byId = new Map(list.map((a) => [a.id, a]));++ return { list: () => [...byId.values()], get: (id) => byId.get(id) || null, ids: () => [...byId.keys()], size: () => byId.size };++}++++(async () => {++ const bot = await startBot();++ const t = await boot({ authorities: registryOf([authority('bot', bot.url, 'bot.resource.control')]) });++ bot.bind(t.network);++ const db = t.ctx.store.db;++ const owner = t.network.addUser('ona');++ const other = t.network.addUser('oto');++ const ROBOT = ids.newId('robot');++ const LOUD = ids.newId('robot');++ const at = new Date().toISOString();++ bot.list([++ { id: ROBOT, kind: 'bot.robot', service: 'bot', name: 'Ona bot', state: 'ready', created_at: at, owner: { type: 'user', id: owner.subject } },++ { id: LOUD, kind: 'bot.robot', service: 'bot', name: 'Talks back', state: 'ready', created_at: at, owner: { type: 'user', id: owner.subject } },++ ]);++ const key = () => `ctl-${crypto.randomBytes(8).toString('hex')}`;++ const svcToken = (cap) => {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:actor', actor_type: 'service', aud: ['openvibe.services'], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, t.network.privatePem);++ };++ const asUser = (u) => `Bearer ${t.network.userToken(u)}`;++ const asService = `Bearer ${svcToken(['services.resource.control'])}`;++ const control = (body, authorization) => t.get('/api/v1/resources/control', { json: body, headers: { authorization } });++ const req = (over = {}) => ({ action: 'stop', owned: { kind: 'bot.robot', id: ROBOT }, owner: { type: 'user', id: owner.subject }, idempotency_key: key(), ...over });++++ await check('the owner\'s own token: forwarded to Bot as Services with bot.resource.control; audited with no project', async () => {++ const r = await control(req(), asUser(owner));++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(resources.checkControlResult(r.json()).valid);++ assert.deepStrictEqual([r.json().state, r.json().result], ['done', { estop: true, changed: true }]);++ const last = bot.seen.at(-1);++ assert.deepStrictEqual([last.body.owned, last.body.owner, last.body.on_behalf_of], [{ kind: 'bot.robot', id: ROBOT }, { type: 'user', id: owner.subject }, { type: 'user', id: owner.subject }]);++ assert.strictEqual(last.body.project_id, undefined);++ assert.ok(last.claims && last.claims.cap.includes('bot.resource.control'), 'Services\' token for Bot holds bot.resource.control');++ const audit = await db.maybe("SELECT * FROM services_audit WHERE target = $1 ORDER BY id DESC LIMIT 1", [`bot.robot/${ROBOT}`]);++ assert.ok(audit && audit.project_id === null && audit.actor === `user:${owner.subject}` && audit.detail.state === 'done', JSON.stringify(audit));++ });++++ await check('someone else, an agent, or a service that names nobody: refused before anything is forwarded', async () => {++ const before = bot.seen.length;++ let r = await control(req(), asUser(other));++ assert.deepStrictEqual([r.status, r.json().code], [403, 'capability.denied'], 'another person cannot name the owner');++ r = await control(req({ on_behalf_of: { type: 'agent', id: ids.newId('agent') } }), asService);++ assert.deepStrictEqual([r.status, r.json().code], [403, 'control.person_only'], 'agents are not offered person-owned resources yet');++ r = await control(req(), asService);++ assert.deepStrictEqual([r.status, r.json().code], [403, 'capability.denied'], 'a service must say whose request it is');++ assert.strictEqual(bot.seen.length, before, 'nothing reached Bot');++ r = await control(req({ on_behalf_of: { type: 'user', id: owner.subject } }), asService);++ assert.deepStrictEqual([r.status, r.json().state], [200, 'done'], 'a first-party service acting for the owner');++ });++++ await check('an authority that names a resource for a person-owned one is a bad answer', async () => {++ const r = await control(req({ owned: { kind: 'bot.robot', id: LOUD } }), asUser(owner));++ assert.deepStrictEqual([r.json().state, r.json().problem.code], ['failed', 'resources.authority_bad_response']);++ });++++ await check('Your resources offers the owner\'s actions; Stop goes through; a stranger\'s attempt is refused by the authority', async () => {++ let r = await t.get('/projects/mine', { as: owner });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.ok(r.text.includes('action="/projects/mine/resources/control"') && r.text.includes(`value="${ROBOT}"`) && r.text.includes('>Stop</button>'), 'a Stop button for the robot');++ r = await t.get('/projects/mine/resources/control', { as: owner, form: { kind: 'bot.robot', id: ROBOT, action: 'stop' } });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ assert.match(r.headers.get('location'), /^\/projects\/mine\?done=Stopped/);++ r = await t.get('/projects/mine/resources/control', { as: other, form: { kind: 'bot.robot', id: ROBOT, action: 'stop' } });++ assert.ok(r.text.includes('The service did not do it') && r.text.includes('resources.unknown_resource'), 'Bot checks the owner it holds');++ r = await t.get('/projects/mine/resources/control', { as: owner, form: { kind: 'community.paste', id: 'my-paste', action: 'delete' } });++ assert.ok(r.status === 200 && r.text.includes('Delete this?') && r.text.includes('name="confirm" value="yes"'), 'delete asks first');++ r = await t.get('/projects/mine/resources/control', { as: owner, form: { kind: 'media.object', id: 'x', action: 'delete' } });++ assert.strictEqual(r.status, 422, 'only the kinds a person owns');++ });++++ await done(t);++ await bot.close();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -91,7 +91,9 @@ function registryOf(list) {await check('only an authority with an active <id>.resource.control takes control requests (Watch 0.165, Events 0.168, Actor and Media 0.169, Space 0.170, Zone 0.173)', async () => {const real = createAuthorities(t.config, { log: { warn() {} } });assert.ok(real.size() > 0);-- const TAKES = { watch: 'watch.resource.control', events: 'events.resource.control', actor: 'actor.resource.control', media: 'media.resource.control', space: 'space.resource.control', zone: 'zone.resource.control' };++ // Person-owned resources too since 0.176.0: bot, chat, openre and community (ADR-048 amendment 2026-10-15).++ const TAKES = { watch: 'watch.resource.control', events: 'events.resource.control', actor: 'actor.resource.control', media: 'media.resource.control', space: 'space.resource.control', zone: 'zone.resource.control',++ bot: 'bot.resource.control', chat: 'chat.resource.control', openre: 'openre.resource.control', community: 'community.resource.control' };for (const a of real.list()) assert.strictEqual(a.control, TAKES[a.id] || null, `${a.id}: the control capability in the pinned contracts`);});
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+-- phase: expand
+-- Person-owned resources (ADR-048 amendment 2026-10-15): a control request for a person's robot, room, restream
+-- definition or paste names no project, so its audit row has none either. Old code always writes one.
+ALTER TABLE services_audit ALTER COLUMN project_id DROP NOT NULL;
@@ -16,7 +16,7 @@
@@ -16,7 +16,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.173.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -890,9 +890,9 @@
@@ -890,9 +890,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.173.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.173.0",
- "integrity": "sha512-6zHbU9zghIYDd3PPGtgMTsIgxrWwsoBHO/rvuLW1rghr6kRK6VwCmSMZRcjQbcPODPH1XrUAFuLg/T1K4nOCMQ==",
+ "version": "0.176.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
+ "integrity": "sha512-COLEoRbjs6he+Tb2LvazezNWvHzB6CEWflebrgEyMaTd1hhE5zWJAAL/mdvR0LOzVDAX6vb1GPdTw83AulLfCg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,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.173.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -98,7 +98,8 @@ function v1Router({ config, apiAuth, index, authorities, control }) {
@@ -98,7 +98,8 @@ function v1Router({ config, apiAuth, index, authorities, control }) {
throw new ServiceError(400, 'control.bad_request', 'the body is not a valid common.resource-control-request@1',
throw new ServiceError(400, 'control.bad_request', 'the body is not a valid common.resource-control-request@1',
{ errors: check.errors.slice(0, 20).map((e) => ({ path: String(e.path || e.instancePath || ''), message: String(e.message || 'invalid') })) });
{ errors: check.errors.slice(0, 20).map((e) => ({ path: String(e.path || e.instancePath || ''), message: String(e.message || 'invalid') })) });
}
}
- await apiAuth.authorizeControl(req, request.project_id);
+ // A project's resource: the caller must control that project. A person's own resource: only its owner (control.run).
+ if (!request.owned) await apiAuth.authorizeControl(req, request.project_id);
const result = await control.run(request, req.principal);
const result = await control.run(request, req.principal);
res.status(httpStatusOf(result)).json(result);
res.status(httpStatusOf(result)).json(result);
});
});
@@ -48,7 +48,7 @@ function canonical(v) {
@@ -48,7 +48,7 @@ function canonical(v) {
const digestOf = (v) => crypto.createHash('sha256').update(canonical(v)).digest('hex');
const digestOf = (v) => crypto.createHash('sha256').update(canonical(v)).digest('hex');
/** What the request asks for, without its delivery fields (idempotency_key, confirmation_id, trace_id). */
/** What the request asks for, without its delivery fields (idempotency_key, confirmation_id, trace_id). */
-const ACTION_FIELDS = ['action', 'resource', 'resource_kind', 'project_id', 'params', 'on_behalf_of', 'dry_run'];
+const ACTION_FIELDS = ['action', 'resource', 'resource_kind', 'owned', 'owner', 'project_id', 'params', 'on_behalf_of', 'dry_run'];
const actionOf = (request) => Object.fromEntries(ACTION_FIELDS.filter((k) => request[k] !== undefined).map((k) => [k, request[k]]));
const actionOf = (request) => Object.fromEntries(ACTION_FIELDS.filter((k) => request[k] !== undefined).map((k) => [k, request[k]]));
const actorOf = (principal) => (principal.kind === 'user' ? `user:${principal.subject}` : principal.sub);
const actorOf = (principal) => (principal.kind === 'user' ? `user:${principal.subject}` : principal.sub);
@@ -113,13 +113,23 @@ function createControl({ store, index, confirmations, log = console }) {
@@ -113,13 +113,23 @@ function createControl({ store, index, confirmations, log = console }) {
}
}
onBehalfOf = { type: 'user', id: principal.subject };
onBehalfOf = { type: 'user', id: principal.subject };
}
}
+ // A person-owned resource (ADR-048 amendment 2026-10-15) is controlled by its owner: their own user token, or a
+ // first-party service acting for them. Services cannot tell whose an agent is, so agents are not offered these.
+ if (request.owned) {
+ if (onBehalfOf && onBehalfOf.type !== 'user') {
+ throw new ServiceError(403, 'control.person_only', 'a person-owned resource is controlled by its owner; agents cannot act on them yet');
+ }
+ if (!onBehalfOf || onBehalfOf.id !== request.owner.id) {
+ throw new ServiceError(403, 'capability.denied', 'only its owner controls a person-owned resource');
+ }
+ }
const forward = { ...request };
const forward = { ...request };
delete forward.confirmation_id;
delete forward.confirmation_id;
if (onBehalfOf) forward.on_behalf_of = onBehalfOf;
if (onBehalfOf) forward.on_behalf_of = onBehalfOf;
const action = actionOf(forward);
const action = actionOf(forward);
const digest = digestOf(action);
const digest = digestOf(action);
- const project = request.project_id;
- const target = request.resource || `kind:${request.resource_kind}`;
+ const project = request.project_id || null;
+ const target = request.resource || (request.owned ? `${request.owned.kind}/${request.owned.id}` : `kind:${request.resource_kind}`);
const key = request.idempotency_key;
const key = request.idempotency_key;
// 1) The idempotency key: claim it, replay its answer, or refuse.
// 1) The idempotency key: claim it, replay its answer, or refuse.
@@ -57,6 +57,16 @@ const KIND_ACTIONS = {
@@ -57,6 +57,16 @@ const KIND_ACTIONS = {
default: { actions: (x) => [x.state === 'paused' ? ['resume', 'Resume'] : ['suspend', 'Suspend'], ['delete', 'Delete…']] },
default: { actions: (x) => [x.state === 'paused' ? ['resume', 'Resume'] : ['suspend', 'Suspend'], ['delete', 'Delete…']] },
};
};
+// What a person may do to their own resources outside any project (ADR-048 amendment 2026-10-15), by kind: the
+// authority's <service>.resource.control lists the same actions and checks that they are the owner.
+const OWNED_ACTIONS = {
+ 'bot.robot': (x) => (x.state === 'estopped' ? [['resume', 'Resume']] : [['stop', 'Stop']]),
+ 'chat.room': () => [['archive', 'Archive…']],
+ 'openre.stream': (x) => (x.state === 'disabled' ? [['resume', 'Resume']] : [['suspend', 'Suspend']]),
+ 'community.paste': () => [['delete', 'Delete…']],
+};
+const OWNED_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
+
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PRJ_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
const CRD_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
const CRD_RE = /^crd_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -173,11 +183,50 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p
@@ -173,11 +183,50 @@ ${table(['Project', 'Your role', 'Environments', 'Apps', 'Created'], list.map((p
heading: html`<h1>Your resources</h1>`,
heading: html`<h1>Your resources</h1>`,
intro: 'What you own yourself, outside any project: your robots, chat rooms, streams, watches, Actor tasks, pastes, forum spaces and the rest. Each OpenVibe service answers for its own, read just now and merged here; the service that holds each one owns it.',
intro: 'What you own yourself, outside any project: your robots, chat rooms, streams, watches, Actor tasks, pastes, forum spaces and the rest. Each OpenVibe service answers for its own, read just now and merged here; the service that holds each one owns it.',
empty: 'Nothing of yours outside a project yet. A robot you add on OpenVibe.Bot, a room you open on OpenVibe.Chat, a stream on OpenRestream, a watch on OpenVibe.Watch or a paste on OpenVibe.Community appears here.',
empty: 'Nothing of yours outside a project yet. A robot you add on OpenVibe.Bot, a room you open on OpenVibe.Chat, a stream on OpenRestream, a watch on OpenVibe.Watch or a paste on OpenVibe.Community appears here.',
- footer: html`Resources in a project are on that project's page: <a href="/projects">your projects</a>.`,
+ footer: html`Resources in a project are on that project's page: <a href="/projects">your projects</a>. Stop or resume a robot, archive a chat room, suspend or resume a stream and delete a paste here: the service that holds each one does it, after checking it is yours.`,
ownerColumn: false,
ownerColumn: false,
+ controls: { owned: true },
});
});
});
});
+ // One control operation on your own resource (ADR-048 amendment 2026-10-15), through server/domain/control.js like
+ // every other: the service that holds it checks you are its owner and decides. Archive and delete ask first.
+ const OWNED_DONE = { stop: 'Stopped', resume: 'Resumed', suspend: 'Suspended', archive: 'Archived', delete: 'Deleted' };
+ r.post('/mine/resources/control', B('services.resource.control'), form, guard, async (req, res) => {
+ const back = '/projects/mine';
+ const b = req.body || {};
+ const action = String(b.action || '');
+ const kind = String(b.kind || '');
+ const id = String(b.id || '');
+ const notDone = (detail, status = 422) => page(req, res, { title: 'Not done', body: html`<h1>Not done</h1><p>${detail}</p><p><a href="${back}">Back</a></p>` }, status);
+ if (!OWNED_ACTIONS[kind] || !OWNED_ID_RE.test(id)) return notDone('That is not one of your resources that can be acted on here.');
+ if (!OWNED_DONE[action]) return notDone('Stop, resume, suspend, archive or delete.');
+ if ((action === 'delete' || action === 'archive') && b.confirm !== 'yes') {
+ const what = action === 'delete' ? 'Delete' : 'Archive';
+ return page(req, res, {
+ title: `${what}?`, crumbs: [{ label: 'Projects', href: '/projects' }, { label: 'Your resources', href: back }, { label: what }],
+ body: html`<h1>${what} this?</h1><p><code>${kind}</code> <code>${id}</code></p><p>${action === 'delete' ? 'The service that holds it deletes it. This cannot be undone here.' : 'The room leaves every listing and takes no new messages; its history stays.'}</p>
+<form method="post" action="/projects/mine/resources/control" class="inline-form">${csrfField(csrf(req))}<input type="hidden" name="kind" value="${kind}"><input type="hidden" name="id" value="${id}"><input type="hidden" name="action" value="${action}"><input type="hidden" name="confirm" value="yes"><button type="submit">${what} it</button></form> <a href="${back}">Cancel</a>`,
+ });
+ }
+ const me = { type: 'user', id: req.viewer.subject };
+ const request = { action, owned: { kind, id }, owner: me, on_behalf_of: me, idempotency_key: `portal-${crypto.randomBytes(12).toString('hex')}` };
+ let result;
+ try {
+ const check = require('openvibe-contracts').resources.checkControlRequest(request);
+ if (!check.valid) throw new ServiceError(400, 'control.bad_request', 'that is not one of your resources');
+ result = await ctx.control.run(request, { kind: 'user', subject: req.viewer.subject });
+ } catch (err) {
+ if (!(err instanceof ServiceError)) throw err;
+ return notDone(html`${err.detail || err.message} (<code>${err.code}</code>)`, err.status || 400);
+ }
+ if (result.state === 'done' || result.state === 'pending') {
+ return res.redirect(303, `${back}?done=${encodeURIComponent(`${OWNED_DONE[action]}${result.state === 'pending' ? ' (the service finishes it shortly)' : ''}: ${kind} ${id}`)}`);
+ }
+ const why = result.problem ? `${result.problem.detail || result.problem.title || ''} (${result.problem.code || result.problem.status})` : result.state;
+ return page(req, res, { title: 'Not done', body: html`<h1>The service did not do it</h1><p>${why}</p><p><a href="${back}">Back to your resources</a></p>` }, 200);
+ });
+
r.get('/:project', idParams, async (req, res) => {
r.get('/:project', idParams, async (req, res) => {
const project = await loadProject(req, res);
const project = await loadProject(req, res);
if (!project) return;
if (!project) return;
@@ -411,6 +460,13 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
@@ -411,6 +460,13 @@ ${got.data.next_before ? html`<p><a href="/projects/${project.id}/audit?before=$
// lets a project's admins act (KIND_ACTIONS `admins`: what its apps made). The service decides either way.
// lets a project's admins act (KIND_ACTIONS `admins`: what its apps made). The service decides either way.
const none = html`<span class="muted">—</span>`;
const none = html`<span class="muted">—</span>`;
const actionsCell = (x) => {
const actionsCell = (x) => {
+ if (controls && controls.owned) {
+ // Your own resources: only what you own, at a service that takes owned control requests.
+ const offer = OWNED_ACTIONS[x.kind];
+ if (!offer || x.ovrn || !controllable.has(x.service) || !(x.owner && x.owner.type === 'user' && x.owner.id === me)) return none;
+ const act = ([action, label]) => html`<form method="post" action="/projects/mine/resources/control" class="inline-form">${csrfField(c)}<input type="hidden" name="kind" value="${x.kind}"><input type="hidden" name="id" value="${x.id}"><input type="hidden" name="action" value="${action}"><button type="submit">${label}</button></form>`;
+ return html`${offer(x).map((o, i) => html`${i ? ' ' : ''}${act(o)}`)}`;
+ }
if (!controls || !x.ovrn || !controllable.has(x.service)) return none;
if (!controls || !x.ovrn || !controllable.has(x.service)) return none;
const kind = KIND_ACTIONS[x.kind] || KIND_ACTIONS.default;
const kind = KIND_ACTIONS[x.kind] || KIND_ACTIONS.default;
const mine = !!(x.owner && x.owner.type === 'user' && x.owner.id === me);
const mine = !!(x.owner && x.owner.type === 'user' && x.owner.id === me);
@@ -174,7 +174,9 @@ function createResourceIndexService({ config, authorities, tokens, self = null,
@@ -174,7 +174,9 @@ function createResourceIndexService({ config, authorities, tokens, self = null,
* { service, adapter }, or ServiceError 400 resources.unknown_service.
* { service, adapter }, or ServiceError 400 resources.unknown_service.
*/
*/
function controlOwner(request) {
function controlOwner(request) {
- const service = request.resource ? parseOvrn(request.resource).service : String(request.resource_kind || '').split('.')[0];
+ // A project's resource by its OVRN or kind; a person-owned one by its index kind (ADR-048 amendment 2026-10-15).
+ const service = request.resource ? parseOvrn(request.resource).service
+ : String((request.owned && request.owned.kind) || request.resource_kind || '').split('.')[0];
const adapter = adapterById.get(service);
const adapter = adapterById.get(service);
if (!adapter) throw new ServiceError(400, 'resources.unknown_service', `${service} is not an authority Services reads`);
if (!adapter) throw new ServiceError(400, 'resources.unknown_service', `${service} is not an authority Services reads`);
return { service, adapter };
return { service, adapter };
@@ -201,6 +203,7 @@ function createResourceIndexService({ config, authorities, tokens, self = null,
@@ -201,6 +203,7 @@ function createResourceIndexService({ config, authorities, tokens, self = null,
if (answer === null) return unsupported();
if (answer === null) return unsupported();
const bad = (detail) => controlResult(request, 'failed', { problem: { status: 502, code: 'resources.authority_bad_response', detail } });
const bad = (detail) => controlResult(request, 'failed', { problem: { status: 502, code: 'resources.authority_bad_response', detail } });
if (!contracts.resources.checkControlResult(answer).valid) return bad(`${service} answered something that is not a common.resource-control-result@1`);
if (!contracts.resources.checkControlResult(answer).valid) return bad(`${service} answered something that is not a common.resource-control-result@1`);
+ if (answer.resource && request.owned) return bad(`${service} answered with a resource name for a person-owned resource`);
if (answer.resource) {
if (answer.resource) {
const named = contracts.resources.parse(answer.resource);
const named = contracts.resources.parse(answer.resource);
if (!named || named.project_id !== request.project_id || named.service !== service) return bad(`${service} answered for a resource outside ${request.project_id}`);
if (!named || named.project_id !== request.project_id || named.service !== service) return bad(`${service} answered for a resource outside ${request.project_id}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+// ADR-048 amendment 2026-10-15: a person controls their own resources outside any project (a robot here) through
+// Services. A common.owned-resource-control-request@1 goes to the authority as Services, with the authority's own
+// capability, only for its owner (their user token, or a first-party service acting for them; never an agent yet);
+// the audit row names no project; "Your resources" offers the actions and asks before destructive ones.
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const { serviceAuth, ids, resources } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+/** A Bot authority: its index answers `listed`; its control checks the token and the owner it holds. */
+async function startBot() {
+ const seen = [];
+ let network = null;
+ let listed = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const send = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
+ if (req.method === 'GET' && req.url.startsWith('/api/v1/resources')) return send(200, { resources: listed, next_cursor: null });
+ if (req.url !== '/api/v1/resources/control' || req.method !== 'POST') return send(404, { type: 'about:blank', title: 'Not found', status: 404, code: 'route.not_found' });
+ const token = String(req.headers.authorization || '').replace(/^Bearer /, '');
+ const v = serviceAuth.verifyServiceToken(token, { publicKey: network.publicPem, issuer: network.url, audience: 'openvibe.bot' });
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ seen.push({ body, claims: v.ok ? v.claims : null });
+ if (!v.ok || !(v.claims.cap || []).includes('bot.resource.control')) return send(403, { type: 'about:blank', title: 'Forbidden', status: 403, code: 'capability.denied' });
+ const at = new Date().toISOString();
+ const row = listed.find((x) => x.id === body.owned.id);
+ if (!row || !row.owner || row.owner.id !== body.owner.id) {
+ return send(200, { action: body.action, state: 'refused', problem: { type: 'about:blank', title: 'resources.unknown_resource', status: 404, code: 'resources.unknown_resource', detail: 'no robot of this person' }, at });
+ }
+ if (row.name === 'Talks back') return send(200, { action: body.action, resource: resources.format({ service: 'bot', project_id: ids.newId('project'), type: 'robot', id: row.id }), state: 'done', at });
+ return send(200, { action: body.action, state: 'done', result: { estop: body.action === 'stop', changed: true }, at });
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return {
+ url: `http://127.0.0.1:${server.address().port}`, seen,
+ bind(n) { network = n; },
+ list(rows) { listed = rows; },
+ close: () => new Promise((r) => server.close(r)),
+ };
+}
+
+const authority = (id, origin, control) => Object.freeze({
+ id, name: id, internalOrigin: origin, publicOrigin: null, audience: `openvibe.${id}`, capability: `${id}.resource.read`,
+ control, manifestVersion: '0.1.0', contractsRange: null, status: 'alpha',
+});
+function registryOf(list) {
+ const byId = new Map(list.map((a) => [a.id, a]));
+ return { list: () => [...byId.values()], get: (id) => byId.get(id) || null, ids: () => [...byId.keys()], size: () => byId.size };
+}
+
+(async () => {
+ const bot = await startBot();
+ const t = await boot({ authorities: registryOf([authority('bot', bot.url, 'bot.resource.control')]) });
+ bot.bind(t.network);
+ const db = t.ctx.store.db;
+ const owner = t.network.addUser('ona');
+ const other = t.network.addUser('oto');
+ const ROBOT = ids.newId('robot');
+ const LOUD = ids.newId('robot');
+ const at = new Date().toISOString();
+ bot.list([
+ { id: ROBOT, kind: 'bot.robot', service: 'bot', name: 'Ona bot', state: 'ready', created_at: at, owner: { type: 'user', id: owner.subject } },
+ { id: LOUD, kind: 'bot.robot', service: 'bot', name: 'Talks back', state: 'ready', created_at: at, owner: { type: 'user', id: owner.subject } },
+ ]);
+ const key = () => `ctl-${crypto.randomBytes(8).toString('hex')}`;
+ const svcToken = (cap) => {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:actor', actor_type: 'service', aud: ['openvibe.services'], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, t.network.privatePem);
+ };
+ const asUser = (u) => `Bearer ${t.network.userToken(u)}`;
+ const asService = `Bearer ${svcToken(['services.resource.control'])}`;
+ const control = (body, authorization) => t.get('/api/v1/resources/control', { json: body, headers: { authorization } });
+ const req = (over = {}) => ({ action: 'stop', owned: { kind: 'bot.robot', id: ROBOT }, owner: { type: 'user', id: owner.subject }, idempotency_key: key(), ...over });
+
+ await check('the owner\'s own token: forwarded to Bot as Services with bot.resource.control; audited with no project', async () => {
+ const r = await control(req(), asUser(owner));
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(resources.checkControlResult(r.json()).valid);
+ assert.deepStrictEqual([r.json().state, r.json().result], ['done', { estop: true, changed: true }]);
+ const last = bot.seen.at(-1);
+ assert.deepStrictEqual([last.body.owned, last.body.owner, last.body.on_behalf_of], [{ kind: 'bot.robot', id: ROBOT }, { type: 'user', id: owner.subject }, { type: 'user', id: owner.subject }]);
+ assert.strictEqual(last.body.project_id, undefined);
+ assert.ok(last.claims && last.claims.cap.includes('bot.resource.control'), 'Services\' token for Bot holds bot.resource.control');
+ const audit = await db.maybe("SELECT * FROM services_audit WHERE target = $1 ORDER BY id DESC LIMIT 1", [`bot.robot/${ROBOT}`]);
+ assert.ok(audit && audit.project_id === null && audit.actor === `user:${owner.subject}` && audit.detail.state === 'done', JSON.stringify(audit));
+ });
+
+ await check('someone else, an agent, or a service that names nobody: refused before anything is forwarded', async () => {
+ const before = bot.seen.length;
+ let r = await control(req(), asUser(other));
+ assert.deepStrictEqual([r.status, r.json().code], [403, 'capability.denied'], 'another person cannot name the owner');
+ r = await control(req({ on_behalf_of: { type: 'agent', id: ids.newId('agent') } }), asService);
+ assert.deepStrictEqual([r.status, r.json().code], [403, 'control.person_only'], 'agents are not offered person-owned resources yet');
+ r = await control(req(), asService);
+ assert.deepStrictEqual([r.status, r.json().code], [403, 'capability.denied'], 'a service must say whose request it is');
+ assert.strictEqual(bot.seen.length, before, 'nothing reached Bot');
+ r = await control(req({ on_behalf_of: { type: 'user', id: owner.subject } }), asService);
+ assert.deepStrictEqual([r.status, r.json().state], [200, 'done'], 'a first-party service acting for the owner');
+ });
+
+ await check('an authority that names a resource for a person-owned one is a bad answer', async () => {
+ const r = await control(req({ owned: { kind: 'bot.robot', id: LOUD } }), asUser(owner));
+ assert.deepStrictEqual([r.json().state, r.json().problem.code], ['failed', 'resources.authority_bad_response']);
+ });
+
+ await check('Your resources offers the owner\'s actions; Stop goes through; a stranger\'s attempt is refused by the authority', async () => {
+ let r = await t.get('/projects/mine', { as: owner });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.ok(r.text.includes('action="/projects/mine/resources/control"') && r.text.includes(`value="${ROBOT}"`) && r.text.includes('>Stop</button>'), 'a Stop button for the robot');
+ r = await t.get('/projects/mine/resources/control', { as: owner, form: { kind: 'bot.robot', id: ROBOT, action: 'stop' } });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ assert.match(r.headers.get('location'), /^\/projects\/mine\?done=Stopped/);
+ r = await t.get('/projects/mine/resources/control', { as: other, form: { kind: 'bot.robot', id: ROBOT, action: 'stop' } });
+ assert.ok(r.text.includes('The service did not do it') && r.text.includes('resources.unknown_resource'), 'Bot checks the owner it holds');
+ r = await t.get('/projects/mine/resources/control', { as: owner, form: { kind: 'community.paste', id: 'my-paste', action: 'delete' } });
+ assert.ok(r.status === 200 && r.text.includes('Delete this?') && r.text.includes('name="confirm" value="yes"'), 'delete asks first');
+ r = await t.get('/projects/mine/resources/control', { as: owner, form: { kind: 'media.object', id: 'x', action: 'delete' } });
+ assert.strictEqual(r.status, 422, 'only the kinds a person owns');
+ });
+
+ await done(t);
+ await bot.close();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -91,7 +91,9 @@ function registryOf(list) {
@@ -91,7 +91,9 @@ function registryOf(list) {
await check('only an authority with an active <id>.resource.control takes control requests (Watch 0.165, Events 0.168, Actor and Media 0.169, Space 0.170, Zone 0.173)', async () => {
await check('only an authority with an active <id>.resource.control takes control requests (Watch 0.165, Events 0.168, Actor and Media 0.169, Space 0.170, Zone 0.173)', async () => {
const real = createAuthorities(t.config, { log: { warn() {} } });
const real = createAuthorities(t.config, { log: { warn() {} } });
assert.ok(real.size() > 0);
assert.ok(real.size() > 0);
- const TAKES = { watch: 'watch.resource.control', events: 'events.resource.control', actor: 'actor.resource.control', media: 'media.resource.control', space: 'space.resource.control', zone: 'zone.resource.control' };
+ // Person-owned resources too since 0.176.0: bot, chat, openre and community (ADR-048 amendment 2026-10-15).
+ const TAKES = { watch: 'watch.resource.control', events: 'events.resource.control', actor: 'actor.resource.control', media: 'media.resource.control', space: 'space.resource.control', zone: 'zone.resource.control',
+ bot: 'bot.resource.control', chat: 'chat.resource.control', openre: 'openre.resource.control', community: 'community.resource.control' };
for (const a of real.list()) assert.strictEqual(a.control, TAKES[a.id] || null, `${a.id}: the control capability in the pinned contracts`);
for (const a of real.list()) assert.strictEqual(a.control, TAKES[a.id] || null, `${a.id}: the control capability in the pinned contracts`);
});
});