openvibers/openvibe.network Commits

HO6: Actor reads, replies to and answers the mail sent to an agent's address (contracts 0.342.0) (#658)

OP OpenVibers committed 5 days ago · 4db4d904d6 · 6 files, +106 −7

Changes

ddiff --git a/README.md b/README.md
iindex 4175254b..8b13f59e 100644
---- a/README.md
++++ b/README.md
@@@ -1027,7 +1027,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
--- openvibe-contracts: v0.338.0
++- openvibe-contracts: v0.342.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.142.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 090f0e21..03a780b0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
-- "contracts": "openvibe-contracts v0.338.0",
++ "contracts": "openvibe-contracts v0.342.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.142.0",
"capabilities": [
ddiff --git a/package-lock.json b/package-lock.json
iindex deaa0ec6..1cf3de8e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
"pg": "^8.23.0",
@@@ -2303,9 +2303,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.338.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
-- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",
++ "version": "0.342.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
++ "integrity": "sha512-gSHwLxPVYSkPN2bZScIlyG6KhxjnKdAIjRomBhm/XngrgfboZR58rfvUG9iLV2ekJPRcX5UxvABL+B/9vtLFkg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex d847317a..ee27c792 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.342.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
"pg": "^8.23.0",
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex bd49c149..35cf9c28 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -872,6 +872,15 @@ const DEFAULT_GRANTS = [
['codes', 'report.session.write', 'openvibe.report', []],
['actor', 'report.session.write', 'openvibe.report', []],
['live', 'report.session.write', 'openvibe.report', []],
++ // HO6 (Contracts 0.342.0): an agent answers the mail sent to its own address. Actor's mail client
++ // (server/mail/client.js) reads one letter, sends the reply the person approved and reads or sets the per-agent
++ // answering switch, all on Mail's audience with the person in X-OV-Subject. Mail's internal routes
++ // (server/http/agent-mail.js) guard exactly these three capabilities and nothing wider, and Media's test keeps
++ // Actor out of its audience: this is the one service that answers mail for the person. The rows are new (never
++ // granted or revoked before) and carry no namespaces, so no RESTORED_GRANTS or CHANGED_DEFAULT_NAMESPACES entry.
++ ['actor', 'mail.agent.read', 'openvibe.mail', []],
++ ['actor', 'mail.agent.reply', 'openvibe.mail', []],
++ ['actor', 'mail.agent.answering', 'openvibe.mail', []],
];
// What a node principal (a person's own machine, paired with its credential) may do for its owner outside Network's
ddiff --git a/test/mail-agent-grants.test.js b/test/mail-agent-grants.test.js
nnew file mode 100644
iindex 00000000..6202d4dc
---- /dev/null
++++ b/test/mail-agent-grants.test.js
@@@ -0,0 +1,90 @@
++'use strict';
++/**
++ * HO6 (Contracts 0.342.0): an agent answers the mail sent to its own address.
++ *
++ * Actor's mail client (server/mail/client.js) mints a service token for the audience openvibe.mail with exactly
++ * mail.agent.read, mail.agent.reply and mail.agent.answering. Mail's internal routes (server/http/agent-mail.js)
++ * guard those three capabilities, and none is wider: it reads one letter, sends the reply the person approved and
++ * reads or sets the per-agent answering switch. Actor is the one service that does this; no other principal holds
++ * the grants, and no other audience is granted them.
++ *
++ * node test/mail-agent-grants.test.js
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const contracts = require('openvibe-contracts');
++const principals = require('../server/identity/principals');
++
++const AUDIENCE = 'openvibe.mail';
++const CAPS = ['mail.agent.read', 'mail.agent.reply', 'mail.agent.answering'];
++const ISSUER = 'https://openvibe.network';
++
++// ── The defaults: Actor and nobody else, Mail's audience and no other ──
++for (const cap of CAPS) {
++ const g = principals.DEFAULT_GRANTS.find((row) => row[0] === 'actor' && row[1] === cap && row[2] === AUDIENCE);
++ assert.ok(g, `Actor holds ${cap} for ${AUDIENCE}`);
++ assert.deepStrictEqual(g[3], [], `${cap} is unscoped: Actor acts for the person in X-OV-Subject`);
++ assert.ok(!principals.DEFAULT_GRANTS.some((row) => row[1] === cap && row[2] !== AUDIENCE),
++ `${cap} is granted for ${AUDIENCE} and no other audience`);
++ assert.ok(!principals.DEFAULT_GRANTS.some((row) => row[0] !== 'actor' && row[1] === cap),
++ `no other client holds ${cap}`);
++
++ const known = contracts.capabilities.get(cap);
++ assert.ok(known, `the pinned openvibe-contracts knows ${cap}`);
++ assert.deepStrictEqual([known.owner, known.status, known.visibility], ['mail', 'active', 'first-party'],
++ `${cap} is Mail's active first-party capability`);
++}
++
++// These rows are new (never granted or revoked before) and carry no namespaces: the seed needs no restore and no
++// namespace move, and no node principal answers mail.
++for (const cap of CAPS) {
++ assert.ok(!principals.REVOKED_GRANTS.some((row) => row[1] === cap), `${cap} was never revoked`);
++ assert.ok(!principals.RESTORED_GRANTS.some((row) => row[1] === cap), `a new default needs no RESTORED_GRANTS entry for ${cap}`);
++ assert.ok(!principals.NODE_GRANTS.some(([, c]) => c === cap), `a person's machine never answers mail (${cap})`);
++}
++assert.ok(!Array.isArray(principals.CHANGED_DEFAULT_NAMESPACES), 'the changed-namespaces list is not exported');
++
++// ── The token: Actor mints exactly these three, and no other service gets them ──
++const keys = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++});
++const rowsFor = (client) => principals.DEFAULT_GRANTS
++ .filter((g) => g[0] === client && g[2] === AUDIENCE)
++ .map((g) => ({ capability: g[1], namespaces: JSON.stringify(g[3] || []) }));
++const dbFor = (client) => ({
++ prepare: (sql) => ({
++ get: async () => (sql.includes('oauth_clients')
++ ? { client_id: client, client_secret: 'the-service-secret' }
++ : null),
++ all: async () => rowsFor(client),
++ }),
++});
++const mint = (client) => principals.issueToken(dbFor(client), {
++ clientId: client, clientSecret: 'the-service-secret', audience: AUDIENCE,
++ scope: CAPS.join(' '), privateKey: keys.privateKey, issuer: ISSUER,
++});
++
++(async () => {
++ const actor = await mint('actor');
++ assert.strictEqual(actor.status, 200, JSON.stringify(actor.body));
++ assert.deepStrictEqual(actor.body.scope.split(' ').sort(), [...CAPS].sort(),
++ 'Actor\'s token carries exactly the three mail.agent capabilities');
++ const v = contracts.serviceAuth.verifyServiceToken(actor.body.access_token, {
++ publicKey: keys.publicKey, issuer: ISSUER, audience: AUDIENCE,
++ });
++ assert.ok(v.ok, v.reason);
++ assert.deepStrictEqual([v.claims.sub, v.claims.actor_type, v.claims.aud],
++ ['svc:actor', 'service', [AUDIENCE]]);
++ assert.deepStrictEqual([...v.claims.cap].sort(), [...CAPS].sort());
++ assert.deepStrictEqual(v.claims.ns, [], 'the grant is unscoped: the person travels in X-OV-Subject');
++ assert.ok(contracts.validate('identity.service-token-claims@1', v.claims).valid,
++ 'the claims fit identity.service-token-claims@1');
++
++ // Any other service asks for the same scopes on Mail's audience and is refused: it has no grant there.
++ const other = await mint('codes');
++ assert.strictEqual(other.status, 400, JSON.stringify(other.body));
++ assert.strictEqual(other.body.error, 'invalid_scope');
++ console.log('mail agent grants: Actor reads, replies and answers; nobody else does');
++})().catch((e) => { console.error(e); process.exit(1); });