openvibers/openvibe.today Commits

Reminders for a person from a first-party service (today.reminder.write), for Vibe (#37)

OP OpenVibers committed 13 days ago · 0bf6a3ded1 · 3 files, +54 −12

Changes

ddiff --git a/server/http/api.js b/server/http/api.js
iindex 423ea13..19d1354 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -53,6 +53,29 @@ function createApi(ctx) {
}
const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);
++ // A person's own reminders: the person (as for everything else here), or a first-party service holding
++ // today.reminder.write that names the person with X-OV-Subject (Vibe's "remind me…", plan L48). An app or a
++ // module never can, and a service acts for one person per request. req.actor = { subject, source }.
++ const USR = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++ function reminderActor(req, res, next) {
++ const p = req.principal;
++ if (p.kind === 'user') {
++ if (!person(req, res)) return undefined;
++ req.actor = { subject: p.subject, source: null };
++ 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 set a person\'s reminders, for that person.');
++ if (!caps.includes('today.reminder.write')) return refuse(res, 403, 'capability.denied', 'This service does not hold today.reminder.write.');
++ 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, source: String(p.requester).slice('service:'.length).replace(/[^a-z0-9-]/g, '').slice(0, 30) || null };
++ return next();
++ }
++ return refuse(res, 401, 'today.sign_in_required', 'Sign in at openvibe.today, or send a person\'s Network token as a Bearer.');
++ }
++
function jsonError(res, out) {
return res.status(out.status || 400).json({ code: out.code || 'today.invalid', ...(out.field ? { field: out.field } : {}), message: out.message || 'invalid request' });
}
@@@ -144,29 +167,29 @@ function createApi(ctx) {
// ── A person's own reminders (plan L48, R1) ────────────
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
-- r.post('/reminders/parse', requirePerson, (req, res) => {
++ r.post('/reminders/parse', reminderActor, (req, res) => {
const b = body(req);
const out = mine.preview({ text: b.text, tz: b.tz });
return out.error ? res.status(422).json({ error: out.error }) : res.json(out);
});
-- r.get('/reminders', requirePerson, async (req, res) => {
-- res.set('Cache-Control', 'private, no-store').json({ reminders: await mine.list(req.principal.subject) });
++ r.get('/reminders', reminderActor, async (req, res) => {
++ res.set('Cache-Control', 'private, no-store').json({ reminders: await mine.list(req.actor.subject) });
});
-- r.post('/reminders', requirePerson, async (req, res) => {
++ r.post('/reminders', reminderActor, async (req, res) => {
const b = body(req);
-- const out = await mine.create(req.principal.subject, { text: b.text, tz: b.tz, url: b.url, source: b.source });
++ const out = await mine.create(req.actor.subject, { text: b.text, tz: b.tz, url: b.url, source: b.source || req.actor.source || 'today' });
return out.error ? res.status(422).json({ error: out.error }) : res.status(201).json(out);
});
-- r.post('/reminders/:id/done', requirePerson, async (req, res) => {
-- const out = await mine.done(req.principal.subject, String(req.params.id));
++ r.post('/reminders/:id/done', reminderActor, async (req, res) => {
++ const out = await mine.done(req.actor.subject, String(req.params.id));
return out ? res.json(out) : res.status(404).json({ error: 'No such reminder' });
});
-- r.post('/reminders/:id/snooze', requirePerson, async (req, res) => {
-- const out = await mine.snooze(req.principal.subject, String(req.params.id), body(req).minutes);
++ r.post('/reminders/:id/snooze', reminderActor, async (req, res) => {
++ const out = await mine.snooze(req.actor.subject, String(req.params.id), body(req).minutes);
return out ? res.json(out) : res.status(404).json({ error: 'No such reminder' });
});
-- r.delete('/reminders/:id', requirePerson, async (req, res) => {
-- return (await mine.remove(req.principal.subject, String(req.params.id))) ? res.status(204).end() : res.status(404).json({ error: 'No such reminder' });
++ r.delete('/reminders/:id', reminderActor, async (req, res) => {
++ 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) => {
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex bc0eb70..b99786d 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -21,7 +21,8 @@ const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
// Today's routes are all a person acting for themself, or a public read. No capability is declared.
--const CAPABILITIES = [];
++// today.reminder.write (Contracts 0.267.0, plan L48): a first-party service sets a person's own reminders for them.
++const CAPABILITIES = ['today.reminder.write'];
function decodePayload(token) {
const parts = String(token || '').split('.');
ddiff --git a/test/my-reminders.test.js b/test/my-reminders.test.js
iindex f686c86..e7507d9 100644
---- a/test/my-reminders.test.js
++++ b/test/my-reminders.test.js
@@@ -86,6 +86,24 @@ const when = require('../server/when');
assert.strictEqual(Number(repRow.sent_count), 1);
});
++ await check('a first-party service with today.reminder.write sets a person\'s reminder for them, and nothing else can', async () => {
++ 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 vibe = token('svc:chat', ['today.reminder.write']);
++ const call = (tok, subject, json) => t.get('/api/v1/reminders', { headers: { authorization: `Bearer ${tok}`, ...(subject ? { 'x-ov-subject': subject } : {}) }, ...(json ? { json } : {}) });
++ const made = await call(vibe, alice.subject, { text: 'stand up and stretch every hour', tz: 'UTC' });
++ assert.strictEqual(made.status, 201, made.text);
++ assert.strictEqual(made.json().source, 'chat', 'it says which site set it');
++ assert.strictEqual(await db.value('SELECT subject FROM today_my_reminders WHERE id = $1', [made.json().id]), alice.subject, 'it is the named person\'s');
++ const listed = (await call(vibe, alice.subject)).json().reminders;
++ assert.ok(listed.some((x) => x.id === made.json().id) && listed.every((x) => true));
++ assert.ok(!(await call(vibe, bob.subject)).json().reminders.some((x) => x.id === made.json().id), 'another person\'s list never shows it');
++ assert.strictEqual((await call(token('svc:games', []), alice.subject, { text: 'x at 3pm', tz: 'UTC' })).status, 403, 'a service without the capability');
++ assert.ok([401, 403].includes((await call(token('app:01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', ['today.reminder.write']), alice.subject, { text: 'x at 3pm', tz: 'UTC' })).status), 'an app never');
++ assert.strictEqual((await call(vibe, null, { text: 'x at 3pm', tz: 'UTC' })).status, 400, 'the person must be named');
++ assert.strictEqual((await t.get('/api/v1/events', { headers: { authorization: `Bearer ${vibe}` }, json: { kind: 'event', title: 'x', starts_at: new Date(Date.now() + 3600e3).toISOString(), tz: 'UTC' } })).status, 403, 'and it reaches nothing but reminders');
++ });
++
await check('/me has the box and the list, and its forms work without the script', async () => {
const me = await t.get('/me', { as: bob });
assert.match(me.text, /<section class="td-card td-remind" id="reminders"/);