openvibers/openvibe.network Commits
HO6: Actor reads, replies to and answers the mail sent to an agent's address (contracts 0.342.0) (#658)
Changes
@@@ -1027,7 +1027,7 @@ Every change also sends the person a Network notification, so a swap nobody askeMIT (`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 -->@@@ -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": [@@@ -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",@@@ -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",@@@ -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'snnew file mode 100644@@@ -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); });
@@ -1027,7 +1027,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
@@ -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)).
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.338.0
+- openvibe-contracts: v0.342.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.142.0
- openvibe-shared: v4.142.0
<!-- versions:end -->
<!-- versions:end -->
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"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",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.142.0",
"shared": "openvibe-shared v4.142.0",
"capabilities": [
"capabilities": [
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2303,9 +2303,9 @@
@@ -2303,9 +2303,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.142.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -872,6 +872,15 @@ const DEFAULT_GRANTS = [
@@ -872,6 +872,15 @@ const DEFAULT_GRANTS = [
['codes', 'report.session.write', 'openvibe.report', []],
['codes', 'report.session.write', 'openvibe.report', []],
['actor', 'report.session.write', 'openvibe.report', []],
['actor', 'report.session.write', 'openvibe.report', []],
['live', '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
// What a node principal (a person's own machine, paired with its credential) may do for its owner outside Network's
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -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); });