openvibers/openvibe.live Commits

VIP first for subscriber perks (W10, proof flow 3): with Billing as the money authority, whether a viewer is subscribed to a channel (channel state, chat and AI perks) is OpenVibe.VIP's entitlement answer through openvibe-sdk/vip (v0.7.0) with Live's service token (vip.entitlement.check); Billing's own answer stands in only when VIP cannot say, and the purchase check still asks Billing. LIVE_VIP_ENTITLEMENTS=off asks Billing only. Inactive while Live is the authority. Test in billing-authority.test.js

OP OpenVibers committed 24 Sept 2026 · e08bbecdb2 · 4 files, +71 −8


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 190f5adf..c1d7f418 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -26,7 +26,7 @@
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.6.0",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
@@@ -2830,9 +2830,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.5.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
-- "integrity": "sha512-QVJkR5iazQX0ZwIrDp2A88ZeYYBSXEmSzhpgURPUSnzezIM3jz0HBhVKowyhkvg2aBO61XNf77eb4zJGkMjZiw==",
++ "version": "0.7.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
++ "integrity": "sha512-G+nevM1cujNypp0oU0Ss1iVTQjbnDRmeUhS5uup6+OBBYQEoUwCBP/d2O3TBioQ2mjroAEbsCTACTxulok/BAw==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 97d95a2c..11042a2e 100644
---- a/package.json
++++ b/package.json
@@@ -44,7 +44,7 @@
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.6.0",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
ddiff --git a/server/monetization/billing-actions.js b/server/monetization/billing-actions.js
iindex 6dceebb4..6b852ba2 100644
---- a/server/monetization/billing-actions.js
++++ b/server/monetization/billing-actions.js
@@@ -325,6 +325,32 @@ async function paypalReturn(token) {
}
// ── Subscriptions and entitlements ───────────────────────────
++// VIP first (roadmap W10, proof flow 3): whether a member is subscribed to a creator, for showing it
++// and for gates, is OpenVibe.VIP's entitlement answer (its projection of Billing's entitlements,
++// through openvibe-sdk/vip with Live's service token, vip.entitlement.check). Billing's own answer
++// stands in only when VIP cannot say (unknown, unreachable, no token), and a purchase decision
++// (subscribe) always asks Billing. LIVE_VIP_ENTITLEMENTS=off asks Billing only.
++let _vip = null;
++function vipClient() {
++ if (_vip) return _vip;
++ const principal = require('../net/network-principal');
++ const { createVipClient } = require('openvibe-sdk/vip');
++ _vip = createVipClient({
++ baseUrl: process.env.OV_VIP_INTERNAL_URL || 'http://127.0.0.1:4620',
++ tokenClient: { authHeaders: () => principal.serviceHeaders('openvibe.vip'), invalidate: () => principal.invalidate('openvibe.vip') },
++ timeoutMs: 2000,
++ });
++ return _vip;
++}
++async function entitlementOf(member, creator) {
++ if (process.env.LIVE_VIP_ENTITLEMENTS !== 'off') {
++ const e = await vipClient().checkEntitlement({ subject: member, creator, product: 'live' });
++ if (e && e.status !== 'unknown') return { active: !!e.active, via: 'vip' };
++ }
++ const e = await api.entitlement(member, creator);
++ return { active: !!(e && e.active), via: 'billing' };
++}
++
function viewerOrNull(fn) { return fn().catch((e) => { if (e instanceof BillingCallError && e.kind === 'no_subject') return null; throw e; }); }
async function subscribe(req, { streamer, provider, autoRenewRaw }) {
@@@ -414,7 +440,7 @@ async function channelState(streamer, viewer) {
if (!to) return { subscribed: false, subscriberCount: 0 };
const me = viewer ? await viewerOrNull(() => subjectFor(viewer.id)) : null;
const [ent, list] = await Promise.all([
-- me ? api.entitlement(me, to) : Promise.resolve(null),
++ me ? entitlementOf(me, to) : Promise.resolve(null),
api.listSubscriptions({ streamer: to, status: 'active' }),
]);
const now = Date.now();
@@@ -455,7 +481,7 @@ function refreshEntitlement(subscriberId, streamerId) {
const p = (async () => {
try {
const [a, b] = await Promise.all([subjectFor(subscriberId), subjectFor(streamerId, { role: 'recipient' })]);
-- const e = await api.entitlement(a, b);
++ const e = await entitlementOf(a, b);
_ent.set(k, { active: !!(e && e.active), at: Date.now() });
} catch (err) {
if (err instanceof BillingCallError && err.kind === 'no_subject') _ent.set(k, { active: false, at: Date.now() });
@@@ -516,5 +542,6 @@ module.exports = {
donate, chargeMedia, linkMediaCharge, refundMedia, requestCashout, recycle, balance, history,
checkout, paypalReturn, subscribe, mySubscriptions, channelState, cancelSubscription,
isSubscriberCached, refreshEntitlement, invalidateEntitlement, adminStatus, resolveAction,
-- _reset() { _ent.clear(); _entInflight.clear(); _tablesReady = false; },
++ entitlementOf,
++ _reset() { _ent.clear(); _entInflight.clear(); _tablesReady = false; _vip = null; },
};
ddiff --git a/test/billing-authority.test.js b/test/billing-authority.test.js
iindex 1e5262ca..ffe4f127 100644
---- a/test/billing-authority.test.js
++++ b/test/billing-authority.test.js
@@@ -260,6 +260,42 @@ async function check(name, fn) {
assert.strictEqual(db.isActiveSubscriber(4, 3), false);
});
++ await check('VIP first: VIP\'s entitlement answer decides the perk; Billing stands in only when VIP cannot say', async () => {
++ const http = require('http');
++ let vipMode = 'inactive';
++ const vipCalls = [];
++ const vip = http.createServer((req, res) => {
++ let b = ''; req.on('data', (c) => { b += c; }); req.on('end', () => {
++ vipCalls.push({ path: req.url, auth: req.headers.authorization || null, body: JSON.parse(b || '{}') });
++ if (vipMode === 'down') { res.writeHead(503, { 'content-type': 'application/json' }); return res.end('{}'); }
++ res.writeHead(200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ status: vipMode, active: vipMode === 'active' }));
++ });
++ });
++ await new Promise((r) => vip.listen(0, '127.0.0.1', r));
++ process.env.OV_VIP_INTERNAL_URL = `http://127.0.0.1:${vip.address().port}`;
++ try {
++ billingActions._reset();
++ assert.strictEqual(await billingActions.refreshEntitlement(2, 3), false, 'VIP says inactive although Billing\'s stub says active');
++ assert.strictEqual(vipCalls[0].path, '/api/v1/entitlements/check');
++ assert.deepStrictEqual(vipCalls[0].body, { subject: SID.ann, creator: vipCalls[0].body.creator, product: 'live' });
++ assert.ok(/^Bearer /.test(vipCalls[0].auth), 'Live\'s service token for openvibe.vip');
++ assert.ok(network.tokens.some((t) => t.aud === 'openvibe.vip'));
++ vipMode = 'down';
++ billingActions._reset();
++ assert.strictEqual(await billingActions.refreshEntitlement(2, 3), true, 'VIP unavailable: Billing\'s answer stands in');
++ process.env.LIVE_VIP_ENTITLEMENTS = 'off';
++ billingActions._reset();
++ const before = vipCalls.length;
++ assert.strictEqual(await billingActions.refreshEntitlement(2, 3), true);
++ assert.strictEqual(vipCalls.length, before, 'switched off: VIP is not asked');
++ } finally {
++ delete process.env.LIVE_VIP_ENTITLEMENTS; delete process.env.OV_VIP_INTERNAL_URL;
++ billingActions._reset();
++ await new Promise((r) => vip.close(r));
++ }
++ });
++
await check('Buy Vibes via PowerChat → POST /intents (intent.create); the link carries Billing\'s checkout ref', async () => {
const n = billing.calls.length;
const r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'powerchat', bucks: 500 });