openvibers/openvibe.network Commits

agent: Host owns its Media tenant again, and Blog can read the shots (plan L9) (#573)

OP OpenVibers committed 7 days ago · ec6bbfff05 · 2 files, +63 −7

Changes

ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 90579cf8..9374ef9a 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -122,6 +122,10 @@ const DEFAULT_GRANTS = [
// OpenVibe.Video's AI edit (MediaHub server/video/ai-edit.js): media.analyze on finished uploads, local and unpaid;
// OpenVibe.Pics alt text (server/pics/describe.js): pics.describe_image on new pictures.
['media-hub', 'ai.run.create', 'openvibe.ai', ['media.analyze', 'pics.describe_image']],
++ // Plan L9 (2026-11-06): the screenshot pass. ovhost shots captures each public page at two viewports and
++ // stores the PNG in OpenVibe.Media, in Host's own tenant (the "host" namespace): Host uploads, lists and
++ // prunes its shots, and Blog reads them to put a before/after in the patch notes.
++ ...['media.object.upload', 'media.object.read', 'media.object.list', 'media.object.delete'].map(cap => ['host', cap, 'openvibe.media', ['host']]),
// Plan T3: Chat owns the six chat tables. Emote images go to Media under the chat namespace, and
// the chat-AI summaries run in Chat with its own token.
['chat', 'media.object.upload', 'openvibe.media', ['chat']],
@@@ -221,7 +225,7 @@ const DEFAULT_GRANTS = [
['blog', 'events.event.publish', 'openvibe.events', []],
['blog', 'community.comment.write', 'openvibe.community', []],
['blog', 'community.comment.moderate', 'openvibe.community', []],
-- ['blog', 'media.object.read', 'openvibe.media', ['blog']],
++ ['blog', 'media.object.read', 'openvibe.media', ['blog', 'host']],
// Wave 10: VIP sells plans through Billing and projects its entitlements from Billing events.
['vip', 'billing.intent.create', 'openvibe.billing', []],
['vip', 'billing.subscription.manage', 'openvibe.billing', []],
@@@ -775,12 +779,8 @@ const REVOKED_GRANTS = [
['space', 'vip.resource.policy.evaluate', 'openvibe.vip'],
['space', 'events.event.read', 'openvibe.events'],
['space', 'network.blocks.read', SELF_AUDIENCE],
-- // The static hosting engine left Host (T20, Host#84, contracts 0.150.0): its Media objects and Services' fan-out to
-- // its resource index go. Space serves the engine.
-- ['host', 'media.object.upload', 'openvibe.media'],
-- ['host', 'media.object.read', 'openvibe.media'],
-- ['host', 'media.object.list', 'openvibe.media'],
-- ['host', 'media.object.delete', 'openvibe.media'],
++ // Plan L9 (2026-11-06): Host's Media grants come back for the screenshot pass (they went with the static
++ // hosting engine in T20). They are in RESTORED_GRANTS below, scoped to the "host" namespace.
['services', 'host.resource.read', 'openvibe.host'],
];
@@@ -800,6 +800,12 @@ const RESTORED_GRANTS = [
['space', 'network.account.deletion.confirm', SELF_AUDIENCE],
// Codes publishes codes.task.* (contracts 0.232.0); revoked when the developer portal left it (0.113.0).
['codes', 'events.event.publish', 'openvibe.events'],
++ // Plan L9 (2026-11-06): Host's Media grants are back (they went when the static hosting engine left Host in
++ // T20): ovhost shots stores the screenshots it captures in OpenVibe.Media, in Host's own tenant.
++ ['host', 'media.object.upload', 'openvibe.media'],
++ ['host', 'media.object.read', 'openvibe.media'],
++ ['host', 'media.object.list', 'openvibe.media'],
++ ['host', 'media.object.delete', 'openvibe.media'],
];
const CHANGED_DEFAULT_NAMESPACES = [
@@@ -823,6 +829,13 @@ const CHANGED_DEFAULT_NAMESPACES = [
['media-hub', 'ai.run.create', 'openvibe.ai', ['media.analyze'], ['media.analyze', 'pics.describe_image']],
// Search's embedder runs AI workflow ai.embed (contracts 0.234.0; POST /api/v1/embed for hybrid ranking).
['search', 'ai.run.create', 'openvibe.ai', ['research.*'], ['research.*', 'ai.embed']],
++ // Plan L9 (2026-11-06): Host's Media rows move from the old host/host.* list to the host namespace alone, and
++ // Blog's Media read reaches Host's tenant too, so the patch notes can read the shots Host captured.
++ ['host', 'media.object.upload', 'openvibe.media', ['host', 'host.*'], ['host']],
++ ['host', 'media.object.read', 'openvibe.media', ['host', 'host.*'], ['host']],
++ ['host', 'media.object.list', 'openvibe.media', ['host', 'host.*'], ['host']],
++ ['host', 'media.object.delete', 'openvibe.media', ['host', 'host.*'], ['host']],
++ ['blog', 'media.object.read', 'openvibe.media', ['blog'], ['blog', 'host']],
// Design's frames with a preference run ai.chat (Design #57, 10-31). Network #533 added it to the default; the
// production row, seeded before, kept ['ai.generate'] (the seed never changes an existing row), so frames still
// failed until this moved it.
ddiff --git a/test/l9-shots-grants.test.js b/test/l9-shots-grants.test.js
nnew file mode 100644
iindex 00000000..f283ffcd
---- /dev/null
++++ b/test/l9-shots-grants.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++// Plan L9 (2026-11-06): the screenshot pass. Host stores the shots ovhost captures in OpenVibe.Media, in its own
++// tenant, and Blog reads them to put a before/after in the patch notes. Both are default grants.
++const assert = require('assert');
++const { getDb } = require('../server/db/database');
++const principals = require('../server/identity/principals');
++
++const HOST_CAPS = ['media.object.upload', 'media.object.read', 'media.object.list', 'media.object.delete'];
++
++(async () => {
++ const db = await getDb();
++ await principals.ensureSchema(db);
++
++ for (const cap of HOST_CAPS) {
++ const def = principals.DEFAULT_GRANTS.find((g) => g[0] === 'host' && g[1] === cap && g[2] === 'openvibe.media');
++ assert.ok(def, `host's ${cap} grant on openvibe.media is a default grant`);
++ assert.deepStrictEqual(def[3], ['host'], `host's ${cap} grant is on the host namespace alone`);
++ assert.ok(!principals.REVOKED_GRANTS.some((g) => g[0] === 'host' && g[1] === cap && g[2] === 'openvibe.media'),
++ `host's ${cap} grant is not revoked any more`);
++ assert.ok(principals.RESTORED_GRANTS.some((g) => g[0] === 'host' && g[1] === cap && g[2] === 'openvibe.media'),
++ `host's ${cap} grant comes back at boot (it is named in RESTORED_GRANTS)`);
++ }
++
++ // The rows are live after boot: not revoked, on the host namespace.
++ for (const cap of HOST_CAPS) {
++ const row = await db.prepare('SELECT revoked_at, namespaces FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?')
++ .get('host', cap, 'openvibe.media');
++ assert.ok(row, `the ${cap} row exists`);
++ assert.strictEqual(row.revoked_at, null, `${cap} is not revoked`);
++ assert.deepStrictEqual(JSON.parse(row.namespaces || '[]'), ['host'], `${cap} is on the host namespace`);
++ }
++
++ // Blog reads its own tenant and Host's, so the patch notes can read the shots.
++ const blog = await db.prepare('SELECT revoked_at, namespaces FROM principal_grants WHERE client_id = ? AND capability = ? AND audience = ?')
++ .get('blog', 'media.object.read', 'openvibe.media');
++ assert.ok(blog, "blog's Media read grant exists");
++ const ns = JSON.parse(blog.namespaces || '[]');
++ assert.ok(ns.includes('blog') && ns.includes('host'), `blog reads its own tenant and Host's: ${JSON.stringify(ns)}`);
++ assert.strictEqual(blog.revoked_at, null, "blog's Media read grant is live");
++
++ console.log('l9 shots grants: Host owns its Media tenant again; Blog can read the shots');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });