openvibers/openvibe.network Commits

F1: notification push by canonical subject, and Cam/Vision grants (#452)

OP OpenVibers committed 11 days ago · 71b40d989c · 3 files, +49 −14

* F1: Cam and Vision push notifications and name a subject

The push route accepts the recipient's canonical usr_ subject when the caller does not hold the numeric Network user id; an unknown subject is 404 and creates nothing. Cam gets network.notifications.push for event alerts and Vision for the consent request, each limited to its own service.

* Merge remote-tracking branch 'origin/main' into refresh-452

# Conflicts:
#	server/identity/principals.js

Changes

ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex f0eb931a..3ad51768 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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.
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex a0fe4b35..ec41f679 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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 });
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex f064217f..872ef400 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -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' });