openvibers/openvibe.network Commits
Grant Tips chat delivery on Chat and Bot identity.subject.resolve (#55)
Changes
@@@ -653,8 +653,16 @@ notification shipped with `openvibe-shared` v2.7.0 (`TYPES.CONFIRMATION_REQUESTE`tokens.issueCode` stores an explicit consented set so an absent or empty `scope` consents to nothing — the codecarries an empty set and `handleTokenRequest` mints a token with `cap: []` (the person stays identified via`on_behalf_of`); asking the token endpoint for an id outside the set is `invalid_scope`, and an app has no refresh-- token to widen it. The account-chooser page renders that list next (`docs/developer-projects.md`, "Consent screen";++ token to widen it. The sign-in page renders that list above every way of signing in (`public/js/login.js` renderConsent; `docs/developer-projects.md`, "Consent screen";`test/consent-screen.test.js`).++- **Tips' Chat delivery is a service grant, not an agent grant** (plan T5, 2026-10-04; Wave 9).++ `DEFAULT_GRANTS` gives `tips` `chat.message.send` and `chat.event.publish` on `openvibe.chat`, beside Live's++ own `chat.message.send` row, because Tips' chat adapter++ (`OpenVibe.Tips server/delivery/chat.js`) mints a first-party service token for exactly that scope++ (`TIPS_CHAT_ADAPTER=chat`) and cannot deliver a paid effect without both rows. Both capabilities are `first-party`++ and `sensitive`, but `sensitive` and confirmations are properties of **agent** grants (section 5): a service++ principal holds them directly, with no confirmation, and the rows do not touch the agent ceiling or the++ grantability rule.### Unresolved@@@ -75,12 +75,16 @@ const DEFAULT_GRANTS = [[svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],]),// Wave 9: Tips starts purchases and transfers in Billing, follows settlement through Events, and-- // announces delivered tips in the creator's Live chat.++ // announces delivered tips in the creator's Live chat. Since plan T5 the announcement itself goes++ // through OpenVibe.Chat's typed ingress, with Tips' own service token (TIPS_CHAT_ADAPTER=chat;++ // OpenVibe.Tips server/delivery/chat.js asks for exactly these two capabilities).['tips', 'billing.intent.create', 'openvibe.billing', []],['tips', 'billing.transfer.create', 'openvibe.billing', []],['tips', 'events.subscription.manage', 'openvibe.events', []],['tips', 'identity.subject.resolve', SELF_AUDIENCE, []],['tips', 'live.tips_delivery.write', 'openvibe.live', []],++ ['tips', 'chat.message.send', 'openvibe.chat', []],++ ['tips', 'chat.event.publish', 'openvibe.chat', []],['live', 'tips.interaction.record', 'openvibe.tips', []],// Wave 7: Live manages its slots' streams, keys and sessions on OpenRe.Stream; OpenRe publishes// session/output events (Live consumes openre.session.* by webhook).@@@ -156,6 +160,8 @@ const DEFAULT_GRANTS = [['host', 'network.resource.report', SELF_AUDIENCE, []],// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.['bot', 'network.node.manage', SELF_AUDIENCE, []],++ // Plan T15: an owner adds an operator by @username on the robot's panel; Bot resolves it to the subject here.++ ['bot', 'identity.subject.resolve', SELF_AUDIENCE, []],// Plan T15: Bot runs each robot's OpenRe stream for its owner (find/create/archive, key rotation), plays the live// session in the panel (session.read), restreams out (output.*) and sets the owner's streaming and recording// toggles on the stream, minting its own token with its Network client instead of a hand-set BOT_OPENRE_TOKEN.@@@ -26,6 +26,7 @@ await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redawait db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('events', 'events-secret', 'OpenVibe.Events', '[]', 1) ON CONFLICT DO NOTHING").run();await db.prepare("UPDATE oauth_clients SET client_secret = 'ai-secret' WHERE client_id = 'ai'").run();await db.prepare("UPDATE oauth_clients SET client_secret = 'bot-secret' WHERE client_id = 'bot'").run();++await db.prepare("UPDATE oauth_clients SET client_secret = 'tips-secret' WHERE client_id = 'tips'").run();await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'payee', 'x'), (8, 'payer', 'x')").run();// A database seeded before media.analyze (Live's AI grants as the old default) is moved at the next boot.await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND audience = 'openvibe.ai'").run(JSON.stringify(['live.*', 'network.site_copy']));@@@ -191,6 +192,9 @@ const server = http.createServer(app);}// Plan T15: Bot mints its own OpenRe token for the robots' streams, sessions, outputs and toggles; nothing beyond OpenRe's// stream, key, session-read and output capabilities (never openre.session.end).++ const bn = await token({ client_id: 'bot', client_secret: 'bot-secret', audience: 'openvibe.network', scope: 'identity.subject.resolve' });++ assert.strictEqual(bn.status, 200, JSON.stringify(bn.body));++ assert.strictEqual(bn.body.scope, 'identity.subject.resolve', 'Bot resolves an operator\'s @username');const bo = await token({ client_id: 'bot', client_secret: 'bot-secret', audience: 'openvibe.openre' });assert.strictEqual(bo.status, 200, JSON.stringify(bo.body));assert.deepStrictEqual(bo.body.scope.split(' ').sort(), ['openre.key.rotate', 'openre.output.read', 'openre.output.write', 'openre.session.read', 'openre.stream.read', 'openre.stream.write'], "Bot runs its robots' OpenRe streams");@@@ -199,6 +203,16 @@ const server = http.createServer(app);const cps = await token({ client_id: 'coupons', client_secret: 'coupons-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' });assert.strictEqual(cps.status, 200, JSON.stringify(cps.body));assert.strictEqual(cps.body.scope, 'events.subscription.manage', 'Coupons manages its Events subscriptions');++ // Plan T5 (Wave 9): Tips' chat adapter mints a token for 'chat.message.send chat.event.publish' on++ // openvibe.chat with its own service token (OpenVibe.Tips server/delivery/chat.js); without both rows++ // TIPS_CHAT_ADAPTER=chat can never deliver a paid effect.++ const tc = await token({ client_id: 'tips', client_secret: 'tips-secret', audience: 'openvibe.chat' });++ assert.strictEqual(tc.status, 200, JSON.stringify(tc.body));++ assert.deepStrictEqual(tc.body.scope.split(' '), ['chat.event.publish', 'chat.message.send'], "Tips delivers paid chat effects through Chat's ingress");++ const tipsClaims = serviceAuth.verifyServiceToken(tc.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.chat' });++ assert.ok(tipsClaims.ok, tipsClaims.reason);++ assert.strictEqual(tipsClaims.claims.sub, 'svc:tips');++ assert.deepStrictEqual(tipsClaims.claims.cap, ['chat.event.publish', 'chat.message.send']);// Go-live fan-out accepts Live's token (network.notifications.push); a narrower token is refused.r = await post('/internal/events/stream-live', {}, { authorization: `Bearer ${full}` });
@@ -653,8 +653,16 @@ notification shipped with `openvibe-shared` v2.7.0 (`TYPES.CONFIRMATION_REQUESTE
@@ -653,8 +653,16 @@ notification shipped with `openvibe-shared` v2.7.0 (`TYPES.CONFIRMATION_REQUESTE
`tokens.issueCode` stores an explicit consented set so an absent or empty `scope` consents to nothing — the code
`tokens.issueCode` stores an explicit consented set so an absent or empty `scope` consents to nothing — the code
carries an empty set and `handleTokenRequest` mints a token with `cap: []` (the person stays identified via
carries an empty set and `handleTokenRequest` mints a token with `cap: []` (the person stays identified via
`on_behalf_of`); asking the token endpoint for an id outside the set is `invalid_scope`, and an app has no refresh
`on_behalf_of`); asking the token endpoint for an id outside the set is `invalid_scope`, and an app has no refresh
- token to widen it. The account-chooser page renders that list next (`docs/developer-projects.md`, "Consent screen";
+ token to widen it. The sign-in page renders that list above every way of signing in (`public/js/login.js` renderConsent; `docs/developer-projects.md`, "Consent screen";
`test/consent-screen.test.js`).
`test/consent-screen.test.js`).
+- **Tips' Chat delivery is a service grant, not an agent grant** (plan T5, 2026-10-04; Wave 9).
+ `DEFAULT_GRANTS` gives `tips` `chat.message.send` and `chat.event.publish` on `openvibe.chat`, beside Live's
+ own `chat.message.send` row, because Tips' chat adapter
+ (`OpenVibe.Tips server/delivery/chat.js`) mints a first-party service token for exactly that scope
+ (`TIPS_CHAT_ADAPTER=chat`) and cannot deliver a paid effect without both rows. Both capabilities are `first-party`
+ and `sensitive`, but `sensitive` and confirmations are properties of **agent** grants (section 5): a service
+ principal holds them directly, with no confirmation, and the rows do not touch the agent ceiling or the
+ grantability rule.
### Unresolved
### Unresolved
@@ -75,12 +75,16 @@ const DEFAULT_GRANTS = [
@@ -75,12 +75,16 @@ const DEFAULT_GRANTS = [
[svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],
[svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],
]),
]),
// Wave 9: Tips starts purchases and transfers in Billing, follows settlement through Events, and
// Wave 9: Tips starts purchases and transfers in Billing, follows settlement through Events, and
- // announces delivered tips in the creator's Live chat.
+ // announces delivered tips in the creator's Live chat. Since plan T5 the announcement itself goes
+ // through OpenVibe.Chat's typed ingress, with Tips' own service token (TIPS_CHAT_ADAPTER=chat;
+ // OpenVibe.Tips server/delivery/chat.js asks for exactly these two capabilities).
['tips', 'billing.intent.create', 'openvibe.billing', []],
['tips', 'billing.intent.create', 'openvibe.billing', []],
['tips', 'billing.transfer.create', 'openvibe.billing', []],
['tips', 'billing.transfer.create', 'openvibe.billing', []],
['tips', 'events.subscription.manage', 'openvibe.events', []],
['tips', 'events.subscription.manage', 'openvibe.events', []],
['tips', 'identity.subject.resolve', SELF_AUDIENCE, []],
['tips', 'identity.subject.resolve', SELF_AUDIENCE, []],
['tips', 'live.tips_delivery.write', 'openvibe.live', []],
['tips', 'live.tips_delivery.write', 'openvibe.live', []],
+ ['tips', 'chat.message.send', 'openvibe.chat', []],
+ ['tips', 'chat.event.publish', 'openvibe.chat', []],
['live', 'tips.interaction.record', 'openvibe.tips', []],
['live', 'tips.interaction.record', 'openvibe.tips', []],
// Wave 7: Live manages its slots' streams, keys and sessions on OpenRe.Stream; OpenRe publishes
// Wave 7: Live manages its slots' streams, keys and sessions on OpenRe.Stream; OpenRe publishes
// session/output events (Live consumes openre.session.* by webhook).
// session/output events (Live consumes openre.session.* by webhook).
@@ -156,6 +160,8 @@ const DEFAULT_GRANTS = [
@@ -156,6 +160,8 @@ const DEFAULT_GRANTS = [
['host', 'network.resource.report', SELF_AUDIENCE, []],
['host', 'network.resource.report', SELF_AUDIENCE, []],
// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.
// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.
['bot', 'network.node.manage', SELF_AUDIENCE, []],
['bot', 'network.node.manage', SELF_AUDIENCE, []],
+ // Plan T15: an owner adds an operator by @username on the robot's panel; Bot resolves it to the subject here.
+ ['bot', 'identity.subject.resolve', SELF_AUDIENCE, []],
// Plan T15: Bot runs each robot's OpenRe stream for its owner (find/create/archive, key rotation), plays the live
// Plan T15: Bot runs each robot's OpenRe stream for its owner (find/create/archive, key rotation), plays the live
// session in the panel (session.read), restreams out (output.*) and sets the owner's streaming and recording
// session in the panel (session.read), restreams out (output.*) and sets the owner's streaming and recording
// toggles on the stream, minting its own token with its Network client instead of a hand-set BOT_OPENRE_TOKEN.
// toggles on the stream, minting its own token with its Network client instead of a hand-set BOT_OPENRE_TOKEN.
@@ -26,6 +26,7 @@ await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, red
@@ -26,6 +26,7 @@ await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, red
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('events', 'events-secret', 'OpenVibe.Events', '[]', 1) ON CONFLICT DO NOTHING").run();
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('events', 'events-secret', 'OpenVibe.Events', '[]', 1) ON CONFLICT DO NOTHING").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'ai-secret' WHERE client_id = 'ai'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'ai-secret' WHERE client_id = 'ai'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'bot-secret' WHERE client_id = 'bot'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'bot-secret' WHERE client_id = 'bot'").run();
+await db.prepare("UPDATE oauth_clients SET client_secret = 'tips-secret' WHERE client_id = 'tips'").run();
await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'payee', 'x'), (8, 'payer', 'x')").run();
await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'payee', 'x'), (8, 'payer', 'x')").run();
// A database seeded before media.analyze (Live's AI grants as the old default) is moved at the next boot.
// A database seeded before media.analyze (Live's AI grants as the old default) is moved at the next boot.
await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND audience = 'openvibe.ai'").run(JSON.stringify(['live.*', 'network.site_copy']));
await db.prepare("UPDATE principal_grants SET namespaces = ? WHERE client_id = 'live' AND audience = 'openvibe.ai'").run(JSON.stringify(['live.*', 'network.site_copy']));
@@ -191,6 +192,9 @@ const server = http.createServer(app);
@@ -191,6 +192,9 @@ const server = http.createServer(app);
}
}
// Plan T15: Bot mints its own OpenRe token for the robots' streams, sessions, outputs and toggles; nothing beyond OpenRe's
// Plan T15: Bot mints its own OpenRe token for the robots' streams, sessions, outputs and toggles; nothing beyond OpenRe's
// stream, key, session-read and output capabilities (never openre.session.end).
// stream, key, session-read and output capabilities (never openre.session.end).
+ const bn = await token({ client_id: 'bot', client_secret: 'bot-secret', audience: 'openvibe.network', scope: 'identity.subject.resolve' });
+ assert.strictEqual(bn.status, 200, JSON.stringify(bn.body));
+ assert.strictEqual(bn.body.scope, 'identity.subject.resolve', 'Bot resolves an operator\'s @username');
const bo = await token({ client_id: 'bot', client_secret: 'bot-secret', audience: 'openvibe.openre' });
const bo = await token({ client_id: 'bot', client_secret: 'bot-secret', audience: 'openvibe.openre' });
assert.strictEqual(bo.status, 200, JSON.stringify(bo.body));
assert.strictEqual(bo.status, 200, JSON.stringify(bo.body));
assert.deepStrictEqual(bo.body.scope.split(' ').sort(), ['openre.key.rotate', 'openre.output.read', 'openre.output.write', 'openre.session.read', 'openre.stream.read', 'openre.stream.write'], "Bot runs its robots' OpenRe streams");
assert.deepStrictEqual(bo.body.scope.split(' ').sort(), ['openre.key.rotate', 'openre.output.read', 'openre.output.write', 'openre.session.read', 'openre.stream.read', 'openre.stream.write'], "Bot runs its robots' OpenRe streams");
@@ -199,6 +203,16 @@ const server = http.createServer(app);
@@ -199,6 +203,16 @@ const server = http.createServer(app);
const cps = await token({ client_id: 'coupons', client_secret: 'coupons-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' });
const cps = await token({ client_id: 'coupons', client_secret: 'coupons-secret', audience: 'openvibe.events', scope: 'events.subscription.manage' });
assert.strictEqual(cps.status, 200, JSON.stringify(cps.body));
assert.strictEqual(cps.status, 200, JSON.stringify(cps.body));
assert.strictEqual(cps.body.scope, 'events.subscription.manage', 'Coupons manages its Events subscriptions');
assert.strictEqual(cps.body.scope, 'events.subscription.manage', 'Coupons manages its Events subscriptions');
+ // Plan T5 (Wave 9): Tips' chat adapter mints a token for 'chat.message.send chat.event.publish' on
+ // openvibe.chat with its own service token (OpenVibe.Tips server/delivery/chat.js); without both rows
+ // TIPS_CHAT_ADAPTER=chat can never deliver a paid effect.
+ const tc = await token({ client_id: 'tips', client_secret: 'tips-secret', audience: 'openvibe.chat' });
+ assert.strictEqual(tc.status, 200, JSON.stringify(tc.body));
+ assert.deepStrictEqual(tc.body.scope.split(' '), ['chat.event.publish', 'chat.message.send'], "Tips delivers paid chat effects through Chat's ingress");
+ const tipsClaims = serviceAuth.verifyServiceToken(tc.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.chat' });
+ assert.ok(tipsClaims.ok, tipsClaims.reason);
+ assert.strictEqual(tipsClaims.claims.sub, 'svc:tips');
+ assert.deepStrictEqual(tipsClaims.claims.cap, ['chat.event.publish', 'chat.message.send']);
// Go-live fan-out accepts Live's token (network.notifications.push); a narrower token is refused.
// Go-live fan-out accepts Live's token (network.notifications.push); a narrower token is refused.
r = await post('/internal/events/stream-live', {}, { authorization: `Bearer ${full}` });
r = await post('/internal/events/stream-live', {}, { authorization: `Bearer ${full}` });