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)

OP OpenVibers committed 29 days ago · 5a47cc4987 · 9 files, +216 −12

"Your resources" now has actions. A person can stop or resume their robot, archive a chat room, suspend or resume a restream definition, and delete a paste. The service that holds each one does it after checking the person is the owner (contracts 0.176.0, ADR-048 amendment: person-owned resources).

**Portal (`/projects/mine`):**
- Each of the viewer's own resources at an authority with an active control capability gets its actions: robot Stop/Resume by its state, room Archive…, stream Suspend/Resume by its state, paste Delete….
- They post to `POST /projects/mine/resources/control` (CSRF, budget `services.resource.control`). Archive and delete ask first.
- The request is a `common.owned-resource-control-request@1` with `owner` and `on_behalf_of` set to the viewer. It is run through `domain/control.js` like every control request: idempotency, log, audit.

**API (`POST /api/v1/resources/control`):**
- An owned request skips the project authorization.
- `control.run` accepts it only from the owner: their own user token, or a first-party service whose `on_behalf_of` names them.
- Any other person is refused (403 `capability.denied`), and so is a service naming nobody. An agent gets 403 `control.person_only`, because Services can't verify an agent's owner yet. Nothing is forwarded in any of these cases.

**Routing (`server/resources.js`):** an owned request goes to the authority named by `owned.kind`. An authority that answers with an OVRN for a person-owned resource is a bad answer.

**Record:** the audit target is `<kind>/<id>`, with `project_id` null. Migration 0005 (expand) drops the column's NOT NULL; old code always writes one.

**Pin:** `openvibe-contracts` v0.173.0 → v0.176.0.

**Tests:**
- `test/control-owned.test.js`, with a fake Bot that checks the owner it holds:
  - the owner's token is forwarded as Services with `bot.resource.control` and audited with no project;
  - another person, an agent and a service naming nobody are refused with nothing forwarded; a service acting for the owner goes through;
  - an OVRN answer is a bad response;
  - the mine page shows Stop, posting it redirects with "Stopped", a stranger's post is refused by the authority, delete asks first, and an unknown kind gets 422.
- `control.test.js` expects bot, chat, openre and community to take control in the pinned contracts.

**Checks:** the full suite passes 45/45.

The authorities are deployed (Bot#107, OpenRestream#69, Community#73; Chat#85 is in CI), and so are Network's grants (#193).

Changes

ddiff --git a/migrations/0005_owned_control.sql b/migrations/0005_owned_control.sql
nnew file mode 100644
iindex 0000000..4e67f32
---- /dev/null
++++ b/migrations/0005_owned_control.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 4ba511e..0ac81dc 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex a1bd345..b0d6594 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 3adaf08..508a6ec 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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);
});
ddiff --git a/server/domain/control.js b/server/domain/control.js
iindex 431fc3b..a0eb9a6 100644
---- a/server/domain/control.js
++++ b/server/domain/control.js
@@@ -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.
ddiff --git a/server/http/portal.js b/server/http/portal.js
iindex b07d749..e88c144 100644
---- a/server/http/portal.js
++++ b/server/http/portal.js
@@@ -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((p
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.',
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);
ddiff --git a/server/resources.js b/server/resources.js
iindex 1081332..92e1483 100644
---- a/server/resources.js
++++ b/server/resources.js
@@@ -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}`);
ddiff --git a/test/control-owned.test.js b/test/control-owned.test.js
nnew file mode 100644
iindex 0000000..4594d6a
---- /dev/null
++++ b/test/control-owned.test.js
@@@ -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); });
ddiff --git a/test/control.test.js b/test/control.test.js
iindex febb6e5..3db484a 100644
---- a/test/control.test.js
++++ b/test/control.test.js
@@@ -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`);
});