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
Changes
@@@ -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"@@@ -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",@@@ -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; },};@@@ -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 });
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.6.0",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -2830,9 +2830,9 @@
@@ -2830,9 +2830,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.6.0",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -325,6 +325,32 @@ async function paypalReturn(token) {
@@ -325,6 +325,32 @@ async function paypalReturn(token) {
}
}
// ── Subscriptions and entitlements ───────────────────────────
// ── 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; }); }
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 }) {
async function subscribe(req, { streamer, provider, autoRenewRaw }) {
@@ -414,7 +440,7 @@ async function channelState(streamer, viewer) {
@@ -414,7 +440,7 @@ async function channelState(streamer, viewer) {
if (!to) return { subscribed: false, subscriberCount: 0 };
if (!to) return { subscribed: false, subscriberCount: 0 };
const me = viewer ? await viewerOrNull(() => subjectFor(viewer.id)) : null;
const me = viewer ? await viewerOrNull(() => subjectFor(viewer.id)) : null;
const [ent, list] = await Promise.all([
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' }),
api.listSubscriptions({ streamer: to, status: 'active' }),
]);
]);
const now = Date.now();
const now = Date.now();
@@ -455,7 +481,7 @@ function refreshEntitlement(subscriberId, streamerId) {
@@ -455,7 +481,7 @@ function refreshEntitlement(subscriberId, streamerId) {
const p = (async () => {
const p = (async () => {
try {
try {
const [a, b] = await Promise.all([subjectFor(subscriberId), subjectFor(streamerId, { role: 'recipient' })]);
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() });
_ent.set(k, { active: !!(e && e.active), at: Date.now() });
} catch (err) {
} catch (err) {
if (err instanceof BillingCallError && err.kind === 'no_subject') _ent.set(k, { active: false, at: Date.now() });
if (err instanceof BillingCallError && err.kind === 'no_subject') _ent.set(k, { active: false, at: Date.now() });
@@ -516,5 +542,6 @@ module.exports = {
@@ -516,5 +542,6 @@ module.exports = {
donate, chargeMedia, linkMediaCharge, refundMedia, requestCashout, recycle, balance, history,
donate, chargeMedia, linkMediaCharge, refundMedia, requestCashout, recycle, balance, history,
checkout, paypalReturn, subscribe, mySubscriptions, channelState, cancelSubscription,
checkout, paypalReturn, subscribe, mySubscriptions, channelState, cancelSubscription,
isSubscriberCached, refreshEntitlement, invalidateEntitlement, adminStatus, resolveAction,
isSubscriberCached, refreshEntitlement, invalidateEntitlement, adminStatus, resolveAction,
- _reset() { _ent.clear(); _entInflight.clear(); _tablesReady = false; },
+ entitlementOf,
+ _reset() { _ent.clear(); _entInflight.clear(); _tablesReady = false; _vip = null; },
};
};
@@ -260,6 +260,42 @@ async function check(name, fn) {
@@ -260,6 +260,42 @@ async function check(name, fn) {
assert.strictEqual(db.isActiveSubscriber(4, 3), false);
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 () => {
await check('Buy Vibes via PowerChat → POST /intents (intent.create); the link carries Billing\'s checkout ref', async () => {
const n = billing.calls.length;
const n = billing.calls.length;
const r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'powerchat', bucks: 500 });
const r = await call('POST', '/api/payments/bucks/checkout', 2, { provider: 'powerchat', bucks: 500 });