openvibers/openvibe.actor Commits

Actor: a first-party service may create a task for a person (X-OV-Subject, recorded as via) (#25)

OP OpenVibers committed 10 Oct 2026 · b4cabec377 · 8 files, +80 −13

Plan T18 step 6 (Watch's actor-task action) needs Actor to accept a task from a first-party service **for** a person. Today a service token creates the task as `service:x`: it spends the service's allowance and is invisible to the person who configured the watch.

- `server/http/principal.js`: a first-party service token (`sub svc:…`, `actor_type: service`) may name the person in `X-OV-Subject`.
  - The principal's requester is then `user:usr_…`, so the person's allowance, daily budget, task list and idempotency keys apply, and the task is exported and erased with their account.
  - The service is kept as `via`.
  - The service still needs the route's capability.
  - A developer app or an agent naming a person is `403 subject.not_delegated`, since an app's person is its token's `on_behalf_of`. A name that is not a `usr_` subject is `400 subject.invalid`.
- **A delegated caller sees only what it started:** `owns()` and `GET /tasks` narrow to `via = <the service>`. A service cannot read, list or cancel the person's other tasks.
- `migrations/0003_task_via.sql` (expand): `tasks.via TEXT`, NULL when the person asked themself. `store.insertTask` writes it. It is not on the wire, since `platform.task@1` has no field for it.
- README and the account-data note are updated.

Test: `test/tasks.test.js`:
- `svc:watch` with `X-OV-Subject` creates the person's task (`requester` is the person, `via` is recorded, and the task is valid `platform.task@1`);
- a retried delivery with the same idempotency key is the same task (200);
- the person lists it;
- the service lists only its own and gets 404 reading or cancelling the person's own task;
- an app naming a person gets 403, a non-user subject 400, and a service without the capability 403.

`ov test` is green (tasks, account-data, resource-index, contracts and the rest).

Changes

ddiff --git a/README.md b/README.md
iindex d2d8aee..28e3b9d 100644
---- a/README.md
++++ b/README.md
@@@ -99,7 +99,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
## Capabilities
--- **Required on this API:** `actor.task.create` (create a task, cancel one), `actor.task.read` (one task, its live stream), `actor.task.list` (list your tasks). A person acting for themself needs no capability; an app, agent or service token for audience `openvibe.actor` needs the one the route names, granted on OpenVibe.Services. `actor.agent.read` is the code's name for the catalog and the dry run, which are public.
++- **Required on this API:** `actor.task.create` (create a task, cancel one), `actor.task.read` (one task, its live stream), `actor.task.list` (list your tasks). A person acting for themself needs no capability; an app, agent or service token for audience `openvibe.actor` needs the one the route names, granted on OpenVibe.Services. A **first-party service** (`svc:`) may act for a person who asked it to by naming them in `X-OV-Subject` (OpenVibe.Watch's actor-task action): the task is the person's (their allowance, their list, their export and deletion) and records the service as `via`; the service still needs the route's capability, and it reads, lists and cancels only the tasks it started. A developer app or an agent never names a person this way (`403 subject.not_delegated`). `actor.agent.read` is the code's name for the catalog and the dry run, which are public.
- **Held on other services:** a client-credentials token for audience `openvibe.events`, scope `events.subscription.manage`, creates the two ADR-033 subscriptions at OpenVibe.Events; the same `actor` principal's token pushes account export parts and deletion confirmations to OpenVibe.Network's internal routes.
- **Not held:** the runtime presents no grant to OpenVibe.Search or OpenVibe.Tools — it uses only what any anonymous caller may call.
ddiff --git a/migrations/0003_task_via.sql b/migrations/0003_task_via.sql
nnew file mode 100644
iindex 0000000..e102501
---- /dev/null
++++ b/migrations/0003_task_via.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- OpenVibe.Actor: a task a first-party service created for a person (X-OV-Subject, e.g. OpenVibe.Watch's actor-task
++-- action) belongs to that person (requester user:usr_…) and records the service here (`service:watch`). NULL: the
++-- requester asked for it themself. A delegated caller sees and cancels only the tasks it started.
++ALTER TABLE tasks ADD COLUMN IF NOT EXISTS via TEXT;
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 837b9f6..b3b2391 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -49,7 +49,8 @@ function createApi(ctx) {
});
// ── Tasks ────────────────────────────────────────────────
-- const owns = (p, row) => row && (row.requester === p.requester || (p.project && row.project_id === p.project));
++ // A delegated caller (a service acting for the person) owns only the person's tasks it started itself.
++ const owns = (p, row) => row && ((row.requester === p.requester && (!p.via || row.via === p.via)) || (p.project && row.project_id === p.project));
async function load(req, res) {
if (!TASK_ID.test(req.params.id)) { problem(req, res, 404, 'actor.task.not_found', 'No such task.'); return null; }
const row = await store.getTask(s, req.params.id);
@@@ -71,7 +72,7 @@ function createApi(ctx) {
r.get('/tasks', principal.requireCapability('actor.task.list'), limits.reads('actor.task.list'), async (req, res) => {
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
const before = typeof req.query.before === 'string' && TASK_ID.test(req.query.before) ? req.query.before : null;
-- const page = await store.listTasks(s, req.principal.requester, { limit, before });
++ const page = await store.listTasks(s, req.principal.requester, { limit, before, via: req.principal.via || null });
res.json({ tasks: page.rows.map(wire), next: page.next });
});
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex bf111c1..497ec64 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -9,6 +9,14 @@
* a Network app, agent or service token for
* audience openvibe.actor; each route names
* ONE capability it needs (actor.task.*)
++ * { kind: 'app', requester: 'user:usr_…', via: 'service:x', project: null, claims }
++ * a first-party service acting for a person
++ * who asked it to (X-OV-Subject: their watch's
++ * actor-task action): the task is the
++ * person's (their allowance, their list) and
++ * records the service; the service still needs
++ * the route's capability and sees only the
++ * tasks it started
* { kind: 'anonymous' }
*
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
@@@ -53,6 +61,15 @@ function createPrincipal({ config, keys }) {
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
++ const named = req.get('x-ov-subject');
++ if (named !== undefined) {
++ // Only a first-party service may act for a person; a developer app or an agent acts as itself (an
++ // app's person is its token's on_behalf_of, never a header).
++ const sub = String(r.claims.sub);
++ if (!sub.startsWith('svc:') || r.claims.actor_type !== 'service') return { error: [403, 'subject.not_delegated', 'only a first-party service may act for a person (X-OV-Subject)'] };
++ if (!ids.isSubjectId('user', named)) return { error: [400, 'subject.invalid', 'X-OV-Subject must be a usr_… subject id'] };
++ return { principal: { kind: 'app', requester: `user:${named}`, via: requesterOfSub(sub), project: null, claims: r.claims } };
++ }
return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
}
// A person's own Network token (the same kind this site's session holds).
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex db0b6e2..ff749de 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -10,8 +10,10 @@
* service's token.
* network.account.deleted both go, and Actor confirms with counts.
*
-- * A task an app, agent or service ran for this person is NOT matched: its requester is `app:…`/`agent:…`/`service:…`,
-- * and Network's deletion event carries only the person's `usr_…`, so only rows requested as `user:usr_…` are erased.
++ * A task an app, agent or service ran as itself is NOT matched: its requester is `app:…`/`agent:…`/`service:…`, and
++ * Network's deletion event carries only the person's `usr_…`, so only rows requested as `user:usr_…` are erased. A task
++ * a first-party service started FOR the person (X-OV-Subject; `via` names the service) is requested as `user:usr_…`:
++ * it is theirs, exported and erased with the rest.
* task_events holds no person column at all — its task_id REFERENCES tasks(id) ON DELETE CASCADE — so a task's event
* log goes with the task and task_events is not in the map. Nothing here is a secret: no token, key or credential is
* stored, so every column of both tables may be exported.
ddiff --git a/server/tasks/create.js b/server/tasks/create.js
iindex 76b9d67..8370786 100644
---- a/server/tasks/create.js
++++ b/server/tasks/create.js
@@@ -48,7 +48,7 @@ async function createTask({ s, config, engine, principal, body }) {
return { status: 503, code: 'actor.capacity.spent', detail: 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.', retryAfter };
}
const row = await store.insertTask(s, {
-- id: s.newId('tsk'), requester: principal.requester, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
++ id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
});
return { status: 201, task: row };
ddiff --git a/server/tasks/store.js b/server/tasks/store.js
iindex 2dfe2b4..4d71548 100644
---- a/server/tasks/store.js
++++ b/server/tasks/store.js
@@@ -22,9 +22,9 @@ function subjectRef(requester) {
async function insertTask(s, t) {
await s.db.query(
-- `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11)`,
-- [t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at]);
++ `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12)`,
++ [t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null]);
await addSpend(s, t.requester, 0, { tasks: 1 });
return getTask(s, t.id);
}
@@@ -32,10 +32,14 @@ async function insertTask(s, t) {
const getTask = (s, id) => s.db.maybe('SELECT * FROM tasks WHERE id = $1', [id]);
const byIdempotency = (s, requester, key) => s.db.maybe('SELECT * FROM tasks WHERE requester = $1 AND idem_key = $2', [requester, key]);
--async function listTasks(s, requester, { limit = 20, before = null } = {}) {
-- const rows = before
-- ? await s.db.many('SELECT * FROM tasks WHERE requester = $1 AND id < $2 ORDER BY id DESC LIMIT $3', [requester, before, limit + 1])
-- : await s.db.many('SELECT * FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT $2', [requester, limit + 1]);
++/** A requester's tasks, newest first; `via` narrows to the ones that service started for them. */
++async function listTasks(s, requester, { limit = 20, before = null, via = null } = {}) {
++ const where = ['requester = $1'];
++ const args = [requester];
++ if (via) { args.push(via); where.push(`via = $${args.length}`); }
++ if (before) { args.push(before); where.push(`id < $${args.length}`); }
++ args.push(limit + 1);
++ const rows = await s.db.many(`SELECT * FROM tasks WHERE ${where.join(' AND ')} ORDER BY id DESC LIMIT $${args.length}`, args);
const page = rows.slice(0, limit);
return { rows: page, next: rows.length > limit ? page[page.length - 1].id : null };
}
ddiff --git a/test/tasks.test.js b/test/tasks.test.js
iindex 153df51..97eef82 100644
---- a/test/tasks.test.js
++++ b/test/tasks.test.js
@@@ -265,6 +265,44 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
assert.strictEqual(read.json().state, 'succeeded');
});
++ await check('a first-party service may act for a person (X-OV-Subject): the task is theirs, the service sees only what it started', async () => {
++ const { serviceAuth } = contracts;
++ const now = Math.floor(Date.now() / 1000);
++ const mint = (sub, actorType, cap, extra = {}) => serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: actorType, aud: ['openvibe.actor'], cap, ns: [], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`, ...extra }, t.network.privatePem);
++ const watch = mint('svc:watch', 'service', ['actor.task.create', 'actor.task.read', 'actor.task.list']);
++ const owner = t.network.addUser('watcher');
++ t.providers.reset();
++ const ok = await t.get('/api/v1/tasks', { bearer: watch, headers: { 'X-OV-Subject': owner.subject }, json: { task: 'Your watch fired: look into it', idempotency_key: 'watch.run_1.0' } });
++ assert.strictEqual(ok.status, 201, ok.text);
++ assert.deepStrictEqual(ok.json().requester, { type: 'user', id: owner.subject }, 'the person\'s task');
++ valid(ok.json());
++ const row = await require('../server/tasks/store').getTask(t.ctx.s, ok.json().id);
++ assert.strictEqual(row.via, 'service:watch', 'and it records who started it');
++ const again = await t.get('/api/v1/tasks', { bearer: watch, headers: { 'X-OV-Subject': owner.subject }, json: { task: 'Your watch fired: look into it', idempotency_key: 'watch.run_1.0' } });
++ assert.deepStrictEqual([again.status, again.json().id], [200, ok.json().id], 'a retried delivery is the same task');
++ await t.waitFor(ok.json().id);
++
++ // The person sees it among their own; the service sees only what it started, not the person's other tasks.
++ const mine = await t.get('/api/v1/tasks', { as: owner });
++ assert.ok(mine.json().tasks.some((x) => x.id === ok.json().id));
++ const own = await t.get('/api/v1/tasks', { as: owner, json: { task: 'something of my own' }, headers: SAME });
++ assert.strictEqual(own.status, 201);
++ await t.waitFor(own.json().id);
++ const listed = await t.get('/api/v1/tasks', { bearer: watch, headers: { 'X-OV-Subject': owner.subject } });
++ assert.deepStrictEqual(listed.json().tasks.map((x) => x.id), [ok.json().id]);
++ assert.strictEqual((await t.get(`/api/v1/tasks/${own.json().id}`, { bearer: watch, headers: { 'X-OV-Subject': owner.subject } })).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/tasks/${own.json().id}/cancel`, { bearer: watch, headers: { 'X-OV-Subject': owner.subject }, method: 'POST' })).status, 404, 'nor cancel it');
++
++ // Only a first-party service may name a person, and only a person.
++ const app = mint('app:app_01JAB2C3D4E5F6G7H8J9K0MNPT', 'app', ['actor.task.create'], { project_id: 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR', env: 'production', ns: ['prj_01JAB2C3D4E5F6G7H8J9K0MNPR'] });
++ let r = await t.get('/api/v1/tasks', { bearer: app, headers: { 'X-OV-Subject': owner.subject }, json: { task: 'x' } });
++ assert.deepStrictEqual([r.status, r.json().code], [403, 'subject.not_delegated']);
++ r = await t.get('/api/v1/tasks', { bearer: watch, headers: { 'X-OV-Subject': 'svc:live' }, json: { task: 'x' } });
++ assert.deepStrictEqual([r.status, r.json().code], [400, 'subject.invalid']);
++ r = await t.get('/api/v1/tasks', { bearer: mint('svc:watch', 'service', ['actor.task.read']), headers: { 'X-OV-Subject': owner.subject }, json: { task: 'x' } });
++ assert.strictEqual(r.status, 403, 'the capability is still the service\'s to hold');
++ });
++
await check('the free tier: tasks a day are counted and refused past the limit with Retry-After', async () => {
const u = t.network.addUser('busy');
const limit = t.config.allowance.tasksPerDay;