openvibers/openvibe.today Commits

Vibe may read a person's agenda for them (today.agenda.read; L48 V3) (#77)

OP OpenVibers committed 11 days ago · 3fb7bd59ec · 5 files, +86 −7

GET /api/v1/me/agenda answers the person, or a first-party service holding today.agenda.read that names the person
with X-OV-Subject (OpenVibe.Chat's Vibe, "plan my day"). Read-only, one person per request, no-store. A service
has no token of the person's, so it gets the follows last read with one, and follows_unavailable when there are
none. An app, a service without the capability, or a request naming nobody is refused.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 16bfd27..995737c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,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.233.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.280.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
"pg": "^8.23.0"
@@@ -1150,9 +1150,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.233.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
-- "integrity": "sha512-pkX0+tsMP6HF7mwbM6jDa97C8U+kbivs+iVdjylEP0sEaqZsPYvTuYHHvnPMZXNQ6QCLriGRSBNdji1F8i3m5A==",
++ "version": "0.280.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.280.0",
++ "integrity": "sha512-OOU5z5gwXZkXl+eWiRJHsbZoNC+nDL3LK+kXHo8TcMlgijTkGyvLIUlKzRDqYXthk0KnYK91dS0VuzRNkVLPIw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex f591bfc..43a3d75 100644
---- a/package.json
++++ b/package.json
@@@ -23,7 +23,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.233.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.280.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
"pg": "^8.23.0"
ddiff --git a/server/agenda.js b/server/agenda.js
iindex 6eddf40..b9e2d66 100644
---- a/server/agenda.js
++++ b/server/agenda.js
@@@ -20,6 +20,10 @@ function createAgenda({ s, follows }) {
const r = await follows.forSubject(subject, token);
followed = r.follows;
unavailable = r.unavailable;
++ } else if (follows.lastGood && follows.lastGood.has(subject)) {
++ // A first-party service reading for the person (today.agenda.read: Vibe's "plan my day") has no token of
++ // theirs: it gets the follows last read with one, and says so when there are none.
++ followed = follows.peek(subject);
} else {
unavailable = true;
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 19d1354..df30985 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -192,10 +192,31 @@ function createApi(ctx) {
return (await mine.remove(req.actor.subject, String(req.params.id))) ? res.status(204).end() : res.status(404).json({ error: 'No such reminder' });
});
-- r.get('/me/agenda', requirePerson, async (req, res) => {
++ // The person's agenda: the person, or a first-party service holding today.agenda.read that names the person with
++ // X-OV-Subject (Vibe's "plan my day", plan L48 V3; contracts 0.280.0). Read-only, one person per request.
++ function agendaActor(req, res, next) {
++ const p = req.principal;
++ if (p.kind === 'user') {
++ if (!person(req, res)) return undefined;
++ req.actor = { subject: p.subject, token: p.token };
++ return next();
++ }
++ if (p.kind === 'app') {
++ const caps = Array.isArray(p.claims && p.claims.cap) ? p.claims.cap : String((p.claims && p.claims.cap) || '').split(/[\s,]+/);
++ if (!String(p.requester || '').startsWith('service:')) return refuse(res, 403, 'today.person_required', 'Only a first-party service may read a person\'s agenda, for that person.');
++ if (!caps.includes('today.agenda.read')) return refuse(res, 403, 'capability.denied', 'This service does not hold today.agenda.read.');
++ const named = String(req.get('x-ov-subject') || '');
++ if (!USR.test(named)) return refuse(res, 400, 'today.subject_required', 'Name the person with X-OV-Subject: usr_…');
++ req.actor = { subject: named, token: null };
++ return next();
++ }
++ return person(req, res) ? next() : undefined;
++ }
++ r.get('/me/agenda', agendaActor, async (req, res) => {
const raw = req.query.days == null || req.query.days === '' ? 14 : Number.parseInt(req.query.days, 10);
if (!Number.isFinite(raw) || raw < 1 || raw > 60) return invalid(res, 'days', 'days must be a whole number from 1 to 60');
-- const out = await agenda.forSubject({ subject: req.principal.subject, token: req.principal.token, days: raw, now: s.now() });
++ const out = await agenda.forSubject({ subject: req.actor.subject, token: req.actor.token, days: raw, now: s.now() });
++ res.set('Cache-Control', 'no-store');
return res.json(out);
});
ddiff --git a/test/agenda-service.test.js b/test/agenda-service.test.js
nnew file mode 100644
iindex 0000000..5552937
---- /dev/null
++++ b/test/agenda-service.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++/**
++ * A person's agenda read by a first-party service for them (today.agenda.read, contracts 0.280.0; plan L48 V3, Vibe's
++ * "plan my day"): the service names the person with X-OV-Subject and gets that person's own agenda, read-only. A
++ * service without the capability, an app, and a request that names nobody are refused; another person's events never
++ * show; the person's own token still reads it as before.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ const at = (min) => new Date(Date.now() + min * 60_000).toISOString();
++ try {
++ const mine = (await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Dentist', starts_at: at(90), tz: 'UTC' } })).json();
++ const theirs = (await t.get('/api/v1/events', { as: bob, json: { kind: 'event', title: 'Bob only', starts_at: at(90), tz: 'UTC' } })).json();
++ const now = Math.floor(Date.now() / 1000);
++ const token = (sub, cap) => t.network.signJwt({ iss: t.network.issuer(), sub, actor_type: sub.startsWith('svc:') ? 'service' : 'app', aud: ['openvibe.today'], cap, iat: now, exp: now + 300, jti: `tok_${Math.random().toString(36).slice(2)}` });
++ const read = (tok, subject, q = '?days=1') => t.get(`/api/v1/me/agenda${q}`, { headers: { authorization: `Bearer ${tok}`, ...(subject ? { 'x-ov-subject': subject } : {}) } });
++
++ await check('Vibe reads the named person\'s day, and only theirs', async () => {
++ const r = await read(token('svc:chat', ['today.agenda.read']), alice.subject);
++ assert.strictEqual(r.status, 200, r.text);
++ const body = r.json();
++ const titles = body.events.map((e) => e.title);
++ assert.ok(titles.includes('Dentist'), titles.join(','));
++ assert.ok(!titles.includes('Bob only'), 'another person\'s event never shows');
++ assert.strictEqual(body.days, 1);
++ assert.strictEqual(body.events.find((e) => e.id === mine.id).own, true);
++ assert.strictEqual(typeof body.follows_unavailable, 'boolean');
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ const v = require('openvibe-contracts').validate('today.agenda@1', body);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ assert.ok(theirs.id);
++ });
++
++ await check('without the capability, as an app, or naming nobody: refused', async () => {
++ assert.strictEqual((await read(token('svc:games', []), alice.subject)).status, 403);
++ assert.strictEqual((await read(token('svc:chat', ['today.reminder.write']), alice.subject)).status, 403, 'writing reminders is not reading the day');
++ assert.ok([401, 403].includes((await read(token('app:01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', ['today.agenda.read']), alice.subject)).status), 'an app never');
++ assert.strictEqual((await read(token('svc:chat', ['today.agenda.read']), null)).status, 400, 'the person must be named');
++ assert.strictEqual((await read(token('svc:chat', ['today.agenda.read']), alice.subject, '?days=90')).status, 400);
++ });
++
++ await check('the person still reads their own agenda with their own sign-in', async () => {
++ const r = await t.get('/api/v1/me/agenda?days=1', { as: alice });
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.json().events.some((e) => e.title === 'Dentist'));
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });