openvibers/openvibe.today Commits
Reminders for a person from a first-party service (today.reminder.write), for Vibe (#37)
Changes
@@@ -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) => {@@@ -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('.');@@@ -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"/);
@@ -53,6 +53,29 @@ function createApi(ctx) {
@@ -53,6 +53,29 @@ function createApi(ctx) {
}
}
const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);
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) {
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' });
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) {
@@ -144,29 +167,29 @@ function createApi(ctx) {
// ── A person's own reminders (plan L48, R1) ────────────
// ── A person's own reminders (plan L48, R1) ────────────
const body = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
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 b = body(req);
const out = mine.preview({ text: b.text, tz: b.tz });
const out = mine.preview({ text: b.text, tz: b.tz });
return out.error ? res.status(422).json({ error: out.error }) : res.json(out);
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 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);
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' });
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' });
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) => {
r.get('/me/agenda', requirePerson, async (req, res) => {
@@ -21,7 +21,8 @@ const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
@@ -21,7 +21,8 @@ const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
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.
// 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) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -86,6 +86,24 @@ const when = require('../server/when');
@@ -86,6 +86,24 @@ const when = require('../server/when');
assert.strictEqual(Number(repRow.sent_count), 1);
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 () => {
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 });
const me = await t.get('/me', { as: bob });
assert.match(me.text, /<section class="td-card td-remind" id="reminders"/);
assert.match(me.text, /<section class="td-card td-remind" id="reminders"/);