openvibers/openvibe.network Commits
F1: notification push by canonical subject, and Cam/Vision grants (#452)
Changes
@@@ -628,6 +628,8 @@ const DEFAULT_GRANTS = [// its account-event subscriptions, and the export/deletion pair above. It has no public records, so no Search grant.['vision', 'ai.run.create', 'openvibe.ai', ['ai.complete']],['vision', 'events.subscription.manage', 'openvibe.events', []],++ // F1: Vision asks the consent check and sends a consent request to a named person who has an account.++ ['vision', 'network.notifications.push', SELF_AUDIENCE, ['vision']],// OpenVibe.Exchange (contracts 0.235.0): its public listings in Search through the owner API (owner exchange), and// its account-event subscriptions; its export/deletion pair is above. v1 writes no grant and runs no sandbox code.['exchange', 'search.document.write', 'openvibe.search', []],@@@ -645,6 +647,8 @@ const DEFAULT_GRANTS = [// token, so it holds no Media, Live or Pics grant. v1 makes nothing public, so no Search grant.['cam', 'identity.subject.resolve', SELF_AUDIENCE, []],['cam', 'events.subscription.manage', 'openvibe.events', []],++ // F1: Cam pushes one notification per familiar-face event (name, unknown person, package, door).++ ['cam', 'network.notifications.push', SELF_AUDIENCE, ['cam']],// OpenVibe.Casino (contracts 0.264.0): its account-event subscriptions at boot; export and deletion are above.['casino', 'events.subscription.manage', 'openvibe.events', []],// OpenVibe.Tours and OpenVibe.Shoes (contracts 0.265.0): their account-event subscriptions at boot.@@@ -242,16 +242,24 @@ router.post('/coins/transfer', principals.guard('network.coins.transfer', { ownA// ── Push Single Notification ─────────────────────────────────// POST /internal/notifications/push--// Body: { user_id, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }++// Body: { user_id | subject, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }++// A service that knows the person's canonical subject (usr_…) may name it instead of the numeric Network user id; the++// notification still belongs to that account. F1's consent request and Cam's event alerts use this.router.post('/notifications/push', principals.guard('network.notifications.push', { ownApp: forService }), async (req, res) => {const notifService = req.app.locals.notificationService;if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });-- const { user_id, ...data } = req.body;-- if (!user_id) return res.status(400).json({ error: 'user_id required' });++ const { user_id, subject, ...data } = req.body;++ let recipient = user_id;++ if (recipient == null && subject != null) {++ const row = await getDb(req).prepare('SELECT id FROM users WHERE subject_id = ?').get(String(subject));++ if (!row) return res.status(404).json({ error: 'no such person' });++ recipient = row.id;++ }++ if (recipient == null) return res.status(400).json({ error: 'user_id or subject required' });try {-- const notification = await notifService.create({ user_id, ...data });++ const notification = await notifService.create({ user_id: recipient, ...data });if (!notification) return res.json({ ok: true, skipped: true, reason: 'User has category disabled' });res.json({ ok: true, notification });} catch (err) {@@@ -262,22 +270,31 @@ router.post('/notifications/push', principals.guard('network.notifications.push'// ── Push Bulk Notifications ──────────────────────────────────// POST /internal/notifications/push-bulk--// Body: { user_ids: [], type, title, message, ... }++// Body: { user_ids: [] | subjects: [], type, title, message, ... }router.post('/notifications/push-bulk', principals.guard('network.notifications.push', { ownApp: forService }), async (req, res) => {const notifService = req.app.locals.notificationService;if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });-- const { user_ids, ...data } = req.body;-- if (!Array.isArray(user_ids) || user_ids.length === 0) {-- return res.status(400).json({ error: 'user_ids array required' });++ const { user_ids, subjects, ...data } = req.body;++ let recipients = user_ids;++ if (!Array.isArray(recipients) && Array.isArray(subjects)) {++ const lookup = getDb(req).prepare('SELECT id FROM users WHERE subject_id = ?');++ recipients = [];++ for (const subject of subjects.slice(0, 1000)) {++ const row = await lookup.get(String(subject));++ if (row) recipients.push(row.id);++ }++ }++ if (!Array.isArray(recipients) || recipients.length === 0) {++ return res.status(400).json({ error: 'user_ids or subjects array required' });}-- if (user_ids.length > 1000) {++ if (recipients.length > 1000) {return res.status(400).json({ error: 'Max 1000 user_ids per request' });}try {-- const results = await notifService.createBulk(user_ids, data);-- res.json({ ok: true, sent: results.length, total: user_ids.length });++ const results = await notifService.createBulk(recipients, data);++ res.json({ ok: true, sent: results.length, total: recipients.length });} catch (err) {console.error('[Internal] Bulk notification push error:', err);res.status(500).json({ error: err.message });@@@ -9,7 +9,7 @@ const os = require('os');const path = require('path');const http = require('http');const express = require('express');--const { serviceAuth, validate } = require('openvibe-contracts');++const { serviceAuth, validate, ids } = require('openvibe-contracts');const { getDb } = require('../server/db/database');(async () => {@@@ -45,6 +45,10 @@ const app = express();app.use(express.json());app.use(express.urlencoded({ extended: true }));app.locals.db = db;++app.locals.notificationService = {++ create: async (data) => ({ id: 'ntf_principals', user_id: data.user_id, type: data.type, title: data.title }),++ createBulk: async (ids, data) => ids.map((id) => ({ id: `ntf_${id}`, user_id: id, type: data.type })),++};app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };app.locals.privateKey = keys.privateKey;app.locals.publicKey = keys.publicKey;@@@ -116,7 +120,7 @@ const server = http.createServer(app);await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(secret, client);t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.network' });// Vision also asks the consent check (L24): it may recognise only people who turned it on.-- const networkCaps = ['network.account.deletion.confirm', 'network.account.export.contribute', ...(client === 'vision' ? ['network.consent.read'] : [])];++ const networkCaps = ['network.account.deletion.confirm', 'network.account.export.contribute', ...(client === 'vision' ? ['network.consent.read', 'network.notifications.push'] : [])];assert.deepStrictEqual(t.body.scope.split(' '), networkCaps, `${client} answers account export and deletion, and holds no identity.subject.resolve`);t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.events' });assert.strictEqual(t.body.scope, 'events.subscription.manage', `${client} subscribes to the account events at boot`);@@@ -158,7 +162,7 @@ const server = http.createServer(app);await db.prepare("UPDATE oauth_clients SET client_secret = 'cam-secret' WHERE client_id = 'cam'").run();t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.network' });-- assert.deepStrictEqual(t.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.consent.read'], 'Cam answers account export and deletion, resolves a grant\'s @username, and asks the consent check before recognising a face (L24)');++ assert.deepStrictEqual(t.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.consent.read', 'network.notifications.push'], 'Cam answers account export and deletion, resolves a grant\'s @username, and asks the consent check before recognising a face (L24)');t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.events' });assert.strictEqual(t.body.scope, 'events.subscription.manage', 'Cam subscribes to the account events at boot');t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.live' });@@@ -268,6 +272,16 @@ const server = http.createServer(app);assert.notStrictEqual(r.status, 403, `network.notifications.push opens push for app search: ${JSON.stringify(r.body)}`);r = await post('/internal/notifications/push', { user_id: 7, title: 'x', service: 'live' }, { authorization: `Bearer ${searchToken}` });assert.strictEqual(r.status, 403, 'search may only push for its own app'); assert.strictEqual(r.body.code, 'capability.owner_denied');++ // F1: a service that knows the canonical subject (Vision's consent request, Cam's event alert) can name it++ // instead of the numeric id; the notification still belongs to that account.++ const subject = ids.newId('user');++ await db.prepare('UPDATE users SET subject_id = ? WHERE id = 7').run(subject);++ const camToken = (await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.network' })).body.access_token;++ r = await post('/internal/notifications/push', { subject, type: 'CAM_EVENT', category: 'cam', title: 'Package left', service: 'cam' }, { authorization: `Bearer ${camToken}` });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.strictEqual(r.body.notification.user_id, 7);++ r = await post('/internal/notifications/push', { subject: ids.newId('user'), title: 'x', service: 'cam' }, { authorization: `Bearer ${camToken}` });++ assert.strictEqual(r.status, 404, 'an unknown subject is not turned into a notification');r = await post('/internal/user-avatar', { user_id: 7, avatar_url: null }, { authorization: `Bearer ${creditOnly}` });assert.strictEqual(r.status, 403, 'a token without network.avatar.write');r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });
@@ -628,6 +628,8 @@ const DEFAULT_GRANTS = [
@@ -628,6 +628,8 @@ const DEFAULT_GRANTS = [
// its account-event subscriptions, and the export/deletion pair above. It has no public records, so no Search grant.
// its account-event subscriptions, and the export/deletion pair above. It has no public records, so no Search grant.
['vision', 'ai.run.create', 'openvibe.ai', ['ai.complete']],
['vision', 'ai.run.create', 'openvibe.ai', ['ai.complete']],
['vision', 'events.subscription.manage', 'openvibe.events', []],
['vision', 'events.subscription.manage', 'openvibe.events', []],
+ // F1: Vision asks the consent check and sends a consent request to a named person who has an account.
+ ['vision', 'network.notifications.push', SELF_AUDIENCE, ['vision']],
// OpenVibe.Exchange (contracts 0.235.0): its public listings in Search through the owner API (owner exchange), and
// OpenVibe.Exchange (contracts 0.235.0): its public listings in Search through the owner API (owner exchange), and
// its account-event subscriptions; its export/deletion pair is above. v1 writes no grant and runs no sandbox code.
// its account-event subscriptions; its export/deletion pair is above. v1 writes no grant and runs no sandbox code.
['exchange', 'search.document.write', 'openvibe.search', []],
['exchange', 'search.document.write', 'openvibe.search', []],
@@ -645,6 +647,8 @@ const DEFAULT_GRANTS = [
@@ -645,6 +647,8 @@ const DEFAULT_GRANTS = [
// token, so it holds no Media, Live or Pics grant. v1 makes nothing public, so no Search grant.
// token, so it holds no Media, Live or Pics grant. v1 makes nothing public, so no Search grant.
['cam', 'identity.subject.resolve', SELF_AUDIENCE, []],
['cam', 'identity.subject.resolve', SELF_AUDIENCE, []],
['cam', 'events.subscription.manage', 'openvibe.events', []],
['cam', 'events.subscription.manage', 'openvibe.events', []],
+ // F1: Cam pushes one notification per familiar-face event (name, unknown person, package, door).
+ ['cam', 'network.notifications.push', SELF_AUDIENCE, ['cam']],
// OpenVibe.Casino (contracts 0.264.0): its account-event subscriptions at boot; export and deletion are above.
// OpenVibe.Casino (contracts 0.264.0): its account-event subscriptions at boot; export and deletion are above.
['casino', 'events.subscription.manage', 'openvibe.events', []],
['casino', 'events.subscription.manage', 'openvibe.events', []],
// OpenVibe.Tours and OpenVibe.Shoes (contracts 0.265.0): their account-event subscriptions at boot.
// OpenVibe.Tours and OpenVibe.Shoes (contracts 0.265.0): their account-event subscriptions at boot.
@@ -242,16 +242,24 @@ router.post('/coins/transfer', principals.guard('network.coins.transfer', { ownA
@@ -242,16 +242,24 @@ router.post('/coins/transfer', principals.guard('network.coins.transfer', { ownA
// ── Push Single Notification ─────────────────────────────────
// ── Push Single Notification ─────────────────────────────────
// POST /internal/notifications/push
// POST /internal/notifications/push
-// Body: { user_id, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }
+// Body: { user_id | subject, type, title, message, icon, sender_id, sender_name, sender_avatar, service, url, priority, category, rich_content, expires_at }
+// A service that knows the person's canonical subject (usr_…) may name it instead of the numeric Network user id; the
+// notification still belongs to that account. F1's consent request and Cam's event alerts use this.
router.post('/notifications/push', principals.guard('network.notifications.push', { ownApp: forService }), async (req, res) => {
router.post('/notifications/push', principals.guard('network.notifications.push', { ownApp: forService }), async (req, res) => {
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
- const { user_id, ...data } = req.body;
- if (!user_id) return res.status(400).json({ error: 'user_id required' });
+ const { user_id, subject, ...data } = req.body;
+ let recipient = user_id;
+ if (recipient == null && subject != null) {
+ const row = await getDb(req).prepare('SELECT id FROM users WHERE subject_id = ?').get(String(subject));
+ if (!row) return res.status(404).json({ error: 'no such person' });
+ recipient = row.id;
+ }
+ if (recipient == null) return res.status(400).json({ error: 'user_id or subject required' });
try {
try {
- const notification = await notifService.create({ user_id, ...data });
+ const notification = await notifService.create({ user_id: recipient, ...data });
if (!notification) return res.json({ ok: true, skipped: true, reason: 'User has category disabled' });
if (!notification) return res.json({ ok: true, skipped: true, reason: 'User has category disabled' });
res.json({ ok: true, notification });
res.json({ ok: true, notification });
} catch (err) {
} catch (err) {
@@ -262,22 +270,31 @@ router.post('/notifications/push', principals.guard('network.notifications.push'
@@ -262,22 +270,31 @@ router.post('/notifications/push', principals.guard('network.notifications.push'
// ── Push Bulk Notifications ──────────────────────────────────
// ── Push Bulk Notifications ──────────────────────────────────
// POST /internal/notifications/push-bulk
// POST /internal/notifications/push-bulk
-// Body: { user_ids: [], type, title, message, ... }
+// Body: { user_ids: [] | subjects: [], type, title, message, ... }
router.post('/notifications/push-bulk', principals.guard('network.notifications.push', { ownApp: forService }), async (req, res) => {
router.post('/notifications/push-bulk', principals.guard('network.notifications.push', { ownApp: forService }), async (req, res) => {
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
- const { user_ids, ...data } = req.body;
- if (!Array.isArray(user_ids) || user_ids.length === 0) {
- return res.status(400).json({ error: 'user_ids array required' });
+ const { user_ids, subjects, ...data } = req.body;
+ let recipients = user_ids;
+ if (!Array.isArray(recipients) && Array.isArray(subjects)) {
+ const lookup = getDb(req).prepare('SELECT id FROM users WHERE subject_id = ?');
+ recipients = [];
+ for (const subject of subjects.slice(0, 1000)) {
+ const row = await lookup.get(String(subject));
+ if (row) recipients.push(row.id);
+ }
+ }
+ if (!Array.isArray(recipients) || recipients.length === 0) {
+ return res.status(400).json({ error: 'user_ids or subjects array required' });
}
}
- if (user_ids.length > 1000) {
+ if (recipients.length > 1000) {
return res.status(400).json({ error: 'Max 1000 user_ids per request' });
return res.status(400).json({ error: 'Max 1000 user_ids per request' });
}
}
try {
try {
- const results = await notifService.createBulk(user_ids, data);
- res.json({ ok: true, sent: results.length, total: user_ids.length });
+ const results = await notifService.createBulk(recipients, data);
+ res.json({ ok: true, sent: results.length, total: recipients.length });
} catch (err) {
} catch (err) {
console.error('[Internal] Bulk notification push error:', err);
console.error('[Internal] Bulk notification push error:', err);
res.status(500).json({ error: err.message });
res.status(500).json({ error: err.message });
@@ -9,7 +9,7 @@ const os = require('os');
@@ -9,7 +9,7 @@ const os = require('os');
const path = require('path');
const path = require('path');
const http = require('http');
const http = require('http');
const express = require('express');
const express = require('express');
-const { serviceAuth, validate } = require('openvibe-contracts');
+const { serviceAuth, validate, ids } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
(async () => {
(async () => {
@@ -45,6 +45,10 @@ const app = express();
@@ -45,6 +45,10 @@ const app = express();
app.use(express.json());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.urlencoded({ extended: true }));
app.locals.db = db;
app.locals.db = db;
+app.locals.notificationService = {
+ create: async (data) => ({ id: 'ntf_principals', user_id: data.user_id, type: data.type, title: data.title }),
+ createBulk: async (ids, data) => ids.map((id) => ({ id: `ntf_${id}`, user_id: id, type: data.type })),
+};
app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
app.locals.privateKey = keys.privateKey;
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.locals.publicKey = keys.publicKey;
@@ -116,7 +120,7 @@ const server = http.createServer(app);
@@ -116,7 +120,7 @@ const server = http.createServer(app);
await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(secret, client);
await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(secret, client);
t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.network' });
t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.network' });
// Vision also asks the consent check (L24): it may recognise only people who turned it on.
// Vision also asks the consent check (L24): it may recognise only people who turned it on.
- const networkCaps = ['network.account.deletion.confirm', 'network.account.export.contribute', ...(client === 'vision' ? ['network.consent.read'] : [])];
+ const networkCaps = ['network.account.deletion.confirm', 'network.account.export.contribute', ...(client === 'vision' ? ['network.consent.read', 'network.notifications.push'] : [])];
assert.deepStrictEqual(t.body.scope.split(' '), networkCaps, `${client} answers account export and deletion, and holds no identity.subject.resolve`);
assert.deepStrictEqual(t.body.scope.split(' '), networkCaps, `${client} answers account export and deletion, and holds no identity.subject.resolve`);
t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.events' });
t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.events' });
assert.strictEqual(t.body.scope, 'events.subscription.manage', `${client} subscribes to the account events at boot`);
assert.strictEqual(t.body.scope, 'events.subscription.manage', `${client} subscribes to the account events at boot`);
@@ -158,7 +162,7 @@ const server = http.createServer(app);
@@ -158,7 +162,7 @@ const server = http.createServer(app);
await db.prepare("UPDATE oauth_clients SET client_secret = 'cam-secret' WHERE client_id = 'cam'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'cam-secret' WHERE client_id = 'cam'").run();
t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.network' });
t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.network' });
- assert.deepStrictEqual(t.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.consent.read'], 'Cam answers account export and deletion, resolves a grant\'s @username, and asks the consent check before recognising a face (L24)');
+ assert.deepStrictEqual(t.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.consent.read', 'network.notifications.push'], 'Cam answers account export and deletion, resolves a grant\'s @username, and asks the consent check before recognising a face (L24)');
t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.events' });
t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.events' });
assert.strictEqual(t.body.scope, 'events.subscription.manage', 'Cam subscribes to the account events at boot');
assert.strictEqual(t.body.scope, 'events.subscription.manage', 'Cam subscribes to the account events at boot');
t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.live' });
t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.live' });
@@ -268,6 +272,16 @@ const server = http.createServer(app);
@@ -268,6 +272,16 @@ const server = http.createServer(app);
assert.notStrictEqual(r.status, 403, `network.notifications.push opens push for app search: ${JSON.stringify(r.body)}`);
assert.notStrictEqual(r.status, 403, `network.notifications.push opens push for app search: ${JSON.stringify(r.body)}`);
r = await post('/internal/notifications/push', { user_id: 7, title: 'x', service: 'live' }, { authorization: `Bearer ${searchToken}` });
r = await post('/internal/notifications/push', { user_id: 7, title: 'x', service: 'live' }, { authorization: `Bearer ${searchToken}` });
assert.strictEqual(r.status, 403, 'search may only push for its own app'); assert.strictEqual(r.body.code, 'capability.owner_denied');
assert.strictEqual(r.status, 403, 'search may only push for its own app'); assert.strictEqual(r.body.code, 'capability.owner_denied');
+ // F1: a service that knows the canonical subject (Vision's consent request, Cam's event alert) can name it
+ // instead of the numeric id; the notification still belongs to that account.
+ const subject = ids.newId('user');
+ await db.prepare('UPDATE users SET subject_id = ? WHERE id = 7').run(subject);
+ const camToken = (await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.network' })).body.access_token;
+ r = await post('/internal/notifications/push', { subject, type: 'CAM_EVENT', category: 'cam', title: 'Package left', service: 'cam' }, { authorization: `Bearer ${camToken}` });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.strictEqual(r.body.notification.user_id, 7);
+ r = await post('/internal/notifications/push', { subject: ids.newId('user'), title: 'x', service: 'cam' }, { authorization: `Bearer ${camToken}` });
+ assert.strictEqual(r.status, 404, 'an unknown subject is not turned into a notification');
r = await post('/internal/user-avatar', { user_id: 7, avatar_url: null }, { authorization: `Bearer ${creditOnly}` });
r = await post('/internal/user-avatar', { user_id: 7, avatar_url: null }, { authorization: `Bearer ${creditOnly}` });
assert.strictEqual(r.status, 403, 'a token without network.avatar.write');
assert.strictEqual(r.status, 403, 'a token without network.avatar.write');
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });