openvibers/openvibe.today Commits
Vibe may read a person's agenda for them (today.agenda.read; L48 V3) (#77)
Changes
@@@ -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",@@@ -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"@@@ -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;}@@@ -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);});nnew file mode 100644@@@ -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); });
@@ -15,7 +15,7 @@
@@ -15,7 +15,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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1150,9 +1150,9 @@
@@ -1150,9 +1150,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -23,7 +23,7 @@
@@ -23,7 +23,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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -20,6 +20,10 @@ function createAgenda({ s, follows }) {
@@ -20,6 +20,10 @@ function createAgenda({ s, follows }) {
const r = await follows.forSubject(subject, token);
const r = await follows.forSubject(subject, token);
followed = r.follows;
followed = r.follows;
unavailable = r.unavailable;
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 {
} else {
unavailable = true;
unavailable = true;
}
}
@@ -192,10 +192,31 @@ function createApi(ctx) {
@@ -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' });
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);
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');
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);
return res.json(out);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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); });