openvibers/openvibe.network Commits
Notify an agent's owner when a confirmation waits for their approval (#47)
Changes
@@@ -228,7 +228,9 @@ minute. Pausing or revoking the agent, revoking or expiring its grant, shrinkingthe project or being erased, revoking the host app and archiving the project cancel the affected pending andapproved-unused confirmations (`cancel_reason` names the cause) and revoke the matching standing rules. The owningservices create, read, consume and cancel them at `/internal/confirmations` ([Service-side agent reads and--confirmations](#service-side-agent-reads-and-confirmations)); agent tokens come in a later slice.++confirmations](#service-side-agent-reads-and-confirmations)); agent tokens come in a later slice. When a new++confirmation is left pending, the owner gets a `CONFIRMATION_REQUESTED` notification (best-effort) whose message is the++agent's name and the summary as plain text, never the details; a standing rule's approval notifies nobody.### Service-side project reads@@@ -23,7 +23,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0","pg": "^8.23.0","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -1601,9 +1601,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.6.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",-- "integrity": "sha512-IKKvWTM/9UonQ4msrzM4yir0QcTUNbhyqeN1r5ywWavU4VTsL1sI5QdHQlxge7BAsMrQ4OvMEPlHjuAfUJM3rg==",++ "version": "2.7.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",++ "integrity": "sha512-Vi5SjtNO8oX5zk6cH4emIFDTSwHpte3NNUVHchZ4gLVg1m5cZ7cUvaqiDvkcz+aw8qyNBw8WQm3A5Y8OS5rxYQ==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -32,7 +32,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0","pg": "^8.23.0","uuid": "^11.1.0","web-push": "^3.6.7"@@@ -139,6 +139,7 @@ async function create(db, { agentId, capability, audience, summary, details, resconst ttl = ttlS == null ? TTL.default : Number(ttlS);if (!Number.isInteger(ttl) || ttl < TTL.min || ttl > TTL.max) invalid(`ttl_s is ${TTL.min}-${TTL.max}`);const id = ids.newId('confirmation');++ let created = null;await db.tx(async () => {const agent = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(agentId);if (!agent) fail(404, 'agent.not_found', 'no such agent');@@@ -164,10 +165,31 @@ async function create(db, { agentId, capability, audience, summary, details, resawait audit(db, row, 'confirmation.created', { actor: `service:${audience}`, detail: { state: row.state }, ctx,event: changed(row, 'created', row.state, { ruleId: rule ? rule.id : null, at: now }) });if (rule) await audit(db, row, 'confirmation.approved', { actor: `rule:${rule.id}`, detail: { rule_id: Number(rule.id), standing_rule: rule.rule }, ctx });++ created = { state: row.state, owner: agent.owner_subject, name: agent.name };});++ if (created.state === 'pending') await notifyOwner(db, created, summary);return confirmationView(await db.prepare('SELECT * FROM dev_confirmations WHERE id = ?').get(id));}++let notifier = null;++function setNotifier(s) { notifier = s && typeof s.create === 'function' ? s : null; }++++/**++ * Tell the owner a confirmation waits for them (CONFIRMATION_REQUESTED), after the create committed. Best-effort: a++ * failure is logged and never undoes the confirmation. The message is the agent's name and the summary, never details.++ */++async function notifyOwner(db, { owner, name }, summary) {++ if (!notifier) return;++ try {++ const u = await db.prepare('SELECT id FROM users WHERE subject_id = ?').get(owner);++ if (!u) return;++ const message = `${String(name || 'An agent').slice(0, 80)}: ${summary}`.slice(0, 200);++ await notifier.create({ user_id: u.id, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', message, url: 'https://openvibe.network/my', service: 'network' });++ } catch (err) {++ console.warn('[Confirmations] owner notify failed:', err.message);++ }++}++/*** Spend an approval once, for the action whose digest is `requestDigest`, asked by the audience that created it. One* transaction: the agent row is locked first so no revocation interleaves, authority is re-checked, then one@@@ -445,6 +467,6 @@ function start(db, { intervalMs = 60_000 } = {}) {function stop() { if (timer) clearInterval(timer); timer = null; }module.exports = {-- create, read, consume, cancel, internalRouter, list, get, decide, cancelFor, expireDue, confirmationView, ruleView, listRules, revokeRule, router, start, stop,++ create, read, consume, cancel, internalRouter, list, get, decide, cancelFor, expireDue, confirmationView, ruleView, listRules, revokeRule, router, start, stop, setNotifier,MAX_PENDING,};@@@ -618,6 +618,7 @@ await require('./developer/event-relay').startRelay(db, { eventsUrl: config.evenawait require('./identity/profile-events').start(db);require('./identity/grants-admin').start(db);// Pending confirmations past expires_at are recorded expired every minute (reads report them expired before that).++require('./developer/confirmations').setNotifier(notificationService);require('./developer/confirmations').start(db);// Notification API (authenticated users)@@@ -419,6 +419,35 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privatassert.deepStrictEqual([i6.status, i6.body.confirmation.state, i6.body.confirmation.standing_rule], [200, 'approved', 'always']);ok(await internal('media', 'POST', `/${i6.body.confirmation.id}/consume`, { request_digest: D }));++ // ── The owner is told once a confirmation waits (CONFIRMATION_REQUESTED), best-effort, never for a rule's approval ──++ const calls = [];++ confirmations.setNotifier({ create: async (n) => { calls.push(n); return { id: 'n1' }; } });++ try {++ const told = await appAgent(W);++ const n1 = await ask(told);++ assert.strictEqual(n1.state, 'pending');++ assert.strictEqual(calls.length, 1);++ const { message, ...rest } = calls[0];++ assert.deepStrictEqual(rest, { user_id: 10, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', url: 'https://openvibe.network/my', service: 'network' });++ assert.strictEqual(message, `Bot: ${SUMMARY}`);++ assert.ok(!message.includes('private-detail-text'));++ await approve(n1, 'owner', { standing_rule: 'always' });++ assert.strictEqual((await ask(told)).state, 'approved');++ assert.strictEqual(calls.length, 1, 'a standing rule\'s approval notifies nobody');++ const xss = await ask(await appAgent(W), 'media.object.delete', { summary: '<b>x</b> & y' });++ assert.strictEqual(calls[1].message, 'Bot: <b>x</b> & y', 'raw text: every consumer escapes it');++ assert.strictEqual(xss.summary, '<b>x</b> & y');++ confirmations.setNotifier({ create: async () => { throw new Error('notify down'); } });++ const warn = console.warn; const warned = []; console.warn = (...a) => warned.push(a.join(' '));++ let n3;++ try { n3 = await ask(await appAgent(W)); } finally { console.warn = warn; }++ assert.strictEqual(n3.state, 'pending');++ assert.strictEqual((await row(n3.id)).state, 'pending');++ assert.ok(warned.some((w) => w.includes('owner notify failed') && w.includes('notify down')), warned.join('\n'));++ } finally {++ confirmations.setNotifier(null);++ }++// ── Audits: every transition, never the summary or the details; each carries its network.confirmation.changed@1// event (slice 9) except a standing rule's approval, which the `created` event already reports ──const audits = await db.prepare("SELECT action, actor, detail, event FROM dev_audit WHERE action LIKE 'confirmation.%' ORDER BY id").all();
@@ -228,7 +228,9 @@ minute. Pausing or revoking the agent, revoking or expiring its grant, shrinking
@@ -228,7 +228,9 @@ minute. Pausing or revoking the agent, revoking or expiring its grant, shrinking
the project or being erased, revoking the host app and archiving the project cancel the affected pending and
the project or being erased, revoking the host app and archiving the project cancel the affected pending and
approved-unused confirmations (`cancel_reason` names the cause) and revoke the matching standing rules. The owning
approved-unused confirmations (`cancel_reason` names the cause) and revoke the matching standing rules. The owning
services create, read, consume and cancel them at `/internal/confirmations` ([Service-side agent reads and
services create, read, consume and cancel them at `/internal/confirmations` ([Service-side agent reads and
-confirmations](#service-side-agent-reads-and-confirmations)); agent tokens come in a later slice.
+confirmations](#service-side-agent-reads-and-confirmations)); agent tokens come in a later slice. When a new
+confirmation is left pending, the owner gets a `CONFIRMATION_REQUESTED` notification (best-effort) whose message is the
+agent's name and the summary as plain text, never the details; a standing rule's approval notifies nobody.
### Service-side project reads
### Service-side project reads
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -1601,9 +1601,9 @@
@@ -1601,9 +1601,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.6.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
- "integrity": "sha512-IKKvWTM/9UonQ4msrzM4yir0QcTUNbhyqeN1r5ywWavU4VTsL1sI5QdHQlxge7BAsMrQ4OvMEPlHjuAfUJM3rg==",
+ "version": "2.7.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",
+ "integrity": "sha512-Vi5SjtNO8oX5zk6cH4emIFDTSwHpte3NNUVHchZ4gLVg1m5cZ7cUvaqiDvkcz+aw8qyNBw8WQm3A5Y8OS5rxYQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.7.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
@@ -139,6 +139,7 @@ async function create(db, { agentId, capability, audience, summary, details, res
@@ -139,6 +139,7 @@ async function create(db, { agentId, capability, audience, summary, details, res
const ttl = ttlS == null ? TTL.default : Number(ttlS);
const ttl = ttlS == null ? TTL.default : Number(ttlS);
if (!Number.isInteger(ttl) || ttl < TTL.min || ttl > TTL.max) invalid(`ttl_s is ${TTL.min}-${TTL.max}`);
if (!Number.isInteger(ttl) || ttl < TTL.min || ttl > TTL.max) invalid(`ttl_s is ${TTL.min}-${TTL.max}`);
const id = ids.newId('confirmation');
const id = ids.newId('confirmation');
+ let created = null;
await db.tx(async () => {
await db.tx(async () => {
const agent = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(agentId);
const agent = await db.prepare('SELECT * FROM dev_agents WHERE id = ? FOR UPDATE').get(agentId);
if (!agent) fail(404, 'agent.not_found', 'no such agent');
if (!agent) fail(404, 'agent.not_found', 'no such agent');
@@ -164,10 +165,31 @@ async function create(db, { agentId, capability, audience, summary, details, res
@@ -164,10 +165,31 @@ async function create(db, { agentId, capability, audience, summary, details, res
await audit(db, row, 'confirmation.created', { actor: `service:${audience}`, detail: { state: row.state }, ctx,
await audit(db, row, 'confirmation.created', { actor: `service:${audience}`, detail: { state: row.state }, ctx,
event: changed(row, 'created', row.state, { ruleId: rule ? rule.id : null, at: now }) });
event: changed(row, 'created', row.state, { ruleId: rule ? rule.id : null, at: now }) });
if (rule) await audit(db, row, 'confirmation.approved', { actor: `rule:${rule.id}`, detail: { rule_id: Number(rule.id), standing_rule: rule.rule }, ctx });
if (rule) await audit(db, row, 'confirmation.approved', { actor: `rule:${rule.id}`, detail: { rule_id: Number(rule.id), standing_rule: rule.rule }, ctx });
+ created = { state: row.state, owner: agent.owner_subject, name: agent.name };
});
});
+ if (created.state === 'pending') await notifyOwner(db, created, summary);
return confirmationView(await db.prepare('SELECT * FROM dev_confirmations WHERE id = ?').get(id));
return confirmationView(await db.prepare('SELECT * FROM dev_confirmations WHERE id = ?').get(id));
}
}
+let notifier = null;
+function setNotifier(s) { notifier = s && typeof s.create === 'function' ? s : null; }
+
+/**
+ * Tell the owner a confirmation waits for them (CONFIRMATION_REQUESTED), after the create committed. Best-effort: a
+ * failure is logged and never undoes the confirmation. The message is the agent's name and the summary, never details.
+ */
+async function notifyOwner(db, { owner, name }, summary) {
+ if (!notifier) return;
+ try {
+ const u = await db.prepare('SELECT id FROM users WHERE subject_id = ?').get(owner);
+ if (!u) return;
+ const message = `${String(name || 'An agent').slice(0, 80)}: ${summary}`.slice(0, 200);
+ await notifier.create({ user_id: u.id, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', message, url: 'https://openvibe.network/my', service: 'network' });
+ } catch (err) {
+ console.warn('[Confirmations] owner notify failed:', err.message);
+ }
+}
+
/**
/**
* Spend an approval once, for the action whose digest is `requestDigest`, asked by the audience that created it. One
* Spend an approval once, for the action whose digest is `requestDigest`, asked by the audience that created it. One
* transaction: the agent row is locked first so no revocation interleaves, authority is re-checked, then one
* transaction: the agent row is locked first so no revocation interleaves, authority is re-checked, then one
@@ -445,6 +467,6 @@ function start(db, { intervalMs = 60_000 } = {}) {
@@ -445,6 +467,6 @@ function start(db, { intervalMs = 60_000 } = {}) {
function stop() { if (timer) clearInterval(timer); timer = null; }
function stop() { if (timer) clearInterval(timer); timer = null; }
module.exports = {
module.exports = {
- create, read, consume, cancel, internalRouter, list, get, decide, cancelFor, expireDue, confirmationView, ruleView, listRules, revokeRule, router, start, stop,
+ create, read, consume, cancel, internalRouter, list, get, decide, cancelFor, expireDue, confirmationView, ruleView, listRules, revokeRule, router, start, stop, setNotifier,
MAX_PENDING,
MAX_PENDING,
};
};
@@ -618,6 +618,7 @@ await require('./developer/event-relay').startRelay(db, { eventsUrl: config.even
@@ -618,6 +618,7 @@ await require('./developer/event-relay').startRelay(db, { eventsUrl: config.even
await require('./identity/profile-events').start(db);
await require('./identity/profile-events').start(db);
require('./identity/grants-admin').start(db);
require('./identity/grants-admin').start(db);
// Pending confirmations past expires_at are recorded expired every minute (reads report them expired before that).
// Pending confirmations past expires_at are recorded expired every minute (reads report them expired before that).
+require('./developer/confirmations').setNotifier(notificationService);
require('./developer/confirmations').start(db);
require('./developer/confirmations').start(db);
// Notification API (authenticated users)
// Notification API (authenticated users)
@@ -419,6 +419,35 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
@@ -419,6 +419,35 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
assert.deepStrictEqual([i6.status, i6.body.confirmation.state, i6.body.confirmation.standing_rule], [200, 'approved', 'always']);
assert.deepStrictEqual([i6.status, i6.body.confirmation.state, i6.body.confirmation.standing_rule], [200, 'approved', 'always']);
ok(await internal('media', 'POST', `/${i6.body.confirmation.id}/consume`, { request_digest: D }));
ok(await internal('media', 'POST', `/${i6.body.confirmation.id}/consume`, { request_digest: D }));
+ // ── The owner is told once a confirmation waits (CONFIRMATION_REQUESTED), best-effort, never for a rule's approval ──
+ const calls = [];
+ confirmations.setNotifier({ create: async (n) => { calls.push(n); return { id: 'n1' }; } });
+ try {
+ const told = await appAgent(W);
+ const n1 = await ask(told);
+ assert.strictEqual(n1.state, 'pending');
+ assert.strictEqual(calls.length, 1);
+ const { message, ...rest } = calls[0];
+ assert.deepStrictEqual(rest, { user_id: 10, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', url: 'https://openvibe.network/my', service: 'network' });
+ assert.strictEqual(message, `Bot: ${SUMMARY}`);
+ assert.ok(!message.includes('private-detail-text'));
+ await approve(n1, 'owner', { standing_rule: 'always' });
+ assert.strictEqual((await ask(told)).state, 'approved');
+ assert.strictEqual(calls.length, 1, 'a standing rule\'s approval notifies nobody');
+ const xss = await ask(await appAgent(W), 'media.object.delete', { summary: '<b>x</b> & y' });
+ assert.strictEqual(calls[1].message, 'Bot: <b>x</b> & y', 'raw text: every consumer escapes it');
+ assert.strictEqual(xss.summary, '<b>x</b> & y');
+ confirmations.setNotifier({ create: async () => { throw new Error('notify down'); } });
+ const warn = console.warn; const warned = []; console.warn = (...a) => warned.push(a.join(' '));
+ let n3;
+ try { n3 = await ask(await appAgent(W)); } finally { console.warn = warn; }
+ assert.strictEqual(n3.state, 'pending');
+ assert.strictEqual((await row(n3.id)).state, 'pending');
+ assert.ok(warned.some((w) => w.includes('owner notify failed') && w.includes('notify down')), warned.join('\n'));
+ } finally {
+ confirmations.setNotifier(null);
+ }
+
// ── Audits: every transition, never the summary or the details; each carries its network.confirmation.changed@1
// ── Audits: every transition, never the summary or the details; each carries its network.confirmation.changed@1
// event (slice 9) except a standing rule's approval, which the `created` event already reports ──
// event (slice 9) except a standing rule's approval, which the `created` event already reports ──
const audits = await db.prepare("SELECT action, actor, detail, event FROM dev_audit WHERE action LIKE 'confirmation.%' ORDER BY id").all();
const audits = await db.prepare("SELECT action, actor, detail, event FROM dev_audit WHERE action LIKE 'confirmation.%' ORDER BY id").all();