openvibers/openvibe.actor Commits
Actor: a first-party service may create a task for a person (X-OV-Subject, recorded as via) (#25)
Changes
@@@ -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.nnew file mode 100644@@@ -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;@@@ -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 });});@@@ -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).@@@ -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.@@@ -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 };@@@ -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 };}@@@ -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;
@@ -99,7 +99,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -99,7 +99,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
## Capabilities
## 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.
- **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.
- **Not held:** the runtime presents no grant to OpenVibe.Search or OpenVibe.Tools — it uses only what any anonymous caller may call.
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -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;
@@ -49,7 +49,8 @@ function createApi(ctx) {
@@ -49,7 +49,8 @@ function createApi(ctx) {
});
});
// ── Tasks ────────────────────────────────────────────────
// ── 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) {
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; }
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);
const row = await store.getTask(s, req.params.id);
@@ -71,7 +72,7 @@ function createApi(ctx) {
@@ -71,7 +72,7 @@ function createApi(ctx) {
r.get('/tasks', principal.requireCapability('actor.task.list'), limits.reads('actor.task.list'), async (req, res) => {
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 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 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 });
res.json({ tasks: page.rows.map(wire), next: page.next });
});
});
@@ -9,6 +9,14 @@
@@ -9,6 +9,14 @@
* a Network app, agent or service token for
* a Network app, agent or service token for
* audience openvibe.actor; each route names
* audience openvibe.actor; each route names
* ONE capability it needs (actor.task.*)
* 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' }
* { kind: 'anonymous' }
*
*
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
* 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 }) {
@@ -53,6 +61,15 @@ function createPrincipal({ config, keys }) {
const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
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'] };
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 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 } };
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).
// A person's own Network token (the same kind this site's session holds).
@@ -10,8 +10,10 @@
@@ -10,8 +10,10 @@
* service's token.
* service's token.
* network.account.deleted both go, and Actor confirms with counts.
* 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
* 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
* 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.
* stored, so every column of both tables may be exported.
@@ -48,7 +48,7 @@ async function createTask({ s, config, engine, principal, body }) {
@@ -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 };
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, {
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(),
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 };
return { status: 201, task: row };
@@ -22,9 +22,9 @@ function subjectRef(requester) {
@@ -22,9 +22,9 @@ function subjectRef(requester) {
async function insertTask(s, t) {
async function insertTask(s, t) {
await s.db.query(
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 });
await addSpend(s, t.requester, 0, { tasks: 1 });
return getTask(s, t.id);
return getTask(s, t.id);
}
}
@@ -32,10 +32,14 @@ async function insertTask(s, t) {
@@ -32,10 +32,14 @@ async function insertTask(s, t) {
const getTask = (s, id) => s.db.maybe('SELECT * FROM tasks WHERE id = $1', [id]);
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]);
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);
const page = rows.slice(0, limit);
return { rows: page, next: rows.length > limit ? page[page.length - 1].id : null };
return { rows: page, next: rows.length > limit ? page[page.length - 1].id : null };
}
}
@@ -265,6 +265,44 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -265,6 +265,44 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
assert.strictEqual(read.json().state, 'succeeded');
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 () => {
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 u = t.network.addUser('busy');
const limit = t.config.allowance.tasksPerDay;
const limit = t.config.allowance.tasksPerDay;