openvibers/openvibe.network Commits

Notify an agent's owner when a confirmation waits for their approval (#47)

OP OpenVibers committed 5 Oct 2026 · fbd63129e1 · 6 files, +61 −7

* Notify an agent's owner when a confirmation waits for their approval

Asked: Part of a cross-repository initiative (init-t2-step-4-dossier-home-works-1004T2305): T2 step 4 (dossier /home/workstation/openvibe/agents/ds/prep/T2.md row 4, WS-Z2 slice 9 remainder): an agent's owner is notified when a confirmation for that agent's sensitive capability use is created and waits fo…
Harness job init-t2-step-4-dossie-2305-s2-233437 (plan T2).

* Send confirmation notices as plain text and document them

Requested by finish-f1. Handed to the local harness by the cloud job pr-OpenVibe.Network-47; done by Claude opus.

Changes

ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex 65aafaa1..b70fbcc6 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -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
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
--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
ddiff --git a/package-lock.json b/package-lock.json
iindex fd1b1f7d..3b2395f7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex b87c83a2..aa769626 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/developer/confirmations.js b/server/developer/confirmations.js
iindex 00957ca7..4c4838ef 100644
---- a/server/developer/confirmations.js
++++ b/server/developer/confirmations.js
@@@ -139,6 +139,7 @@ async function create(db, { agentId, capability, audience, summary, details, res
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}`);
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, res
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 }) });
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,
};
ddiff --git a/server/index.js b/server/index.js
iindex c075a312..2ae597e0 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -618,6 +618,7 @@ await require('./developer/event-relay').startRelay(db, { eventsUrl: config.even
await 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)
ddiff --git a/test/confirmations.test.js b/test/confirmations.test.js
iindex ee0dec10..35a1bc45 100644
---- a/test/confirmations.test.js
++++ b/test/confirmations.test.js
@@@ -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']);
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();