openvibers/openvibe.live Commits
Live: OpenCoins come only from the Network wallet; nothing reads users.openvibe_coins_balance (#80)
Changes
@@@ -768,12 +768,13 @@ async function loadBalance() {// Navbar OpenCoins = the GLOBAL currency (game / cosmetics / media wallet).try {const coinData = await api('/coins/balance');-- const coins = coinData.balance || 0;++ // null: the Network wallet did not answer. Say so ("—") rather than show 0 as the balance.++ const coins = coinData.balance == null ? '—' : Number(coinData.balance).toLocaleString();const coinEl = document.getElementById('nav-coins-amount');-- if (coinEl) coinEl.textContent = coins.toLocaleString();-- if (window.LiveNav) window.LiveNav.setChip('coins', coins.toLocaleString());++ if (coinEl) coinEl.textContent = coins;++ if (window.LiveNav) window.LiveNav.setChip('coins', coins);const udC = document.getElementById('ud-coins');-- if (udC) udC.textContent = coins.toLocaleString();++ if (udC) udC.textContent = coins;} catch { /* silent */ }}@@@ -461,7 +461,6 @@</div></div>`;}-- const coins = p.openvibe_coins_balance || 0;// Chat answers the message count; a cold cache leaves it null and the card omits the stat// rather than showing a stale mirror number as real.const msgs = p.messageCount == null ? null : Number(p.messageCount);@@@ -479,7 +478,6 @@</div></div><div class="ctx-stats">-- <div class="ctx-stat"><i class="fa-solid fa-coins"></i> ${numf(coins)}</div>${msgs == null ? '' : `<div class="ctx-stat"><i class="fa-solid fa-message"></i> ${numf(msgs)}</div>`}<div class="ctx-stat"><i class="fa-solid fa-heart"></i> ${numf(p.followerCount || 0)}</div></div>@@@ -1163,8 +1163,7 @@ async function loadDashCoins() {if (!currentUser) return;try {const data = await api('/coins/balance');-- const bal = data.balance || 0;-- document.getElementById('dash-coins-amount').textContent = bal.toLocaleString();++ document.getElementById('dash-coins-amount').textContent = data.balance == null ? '—' : Number(data.balance).toLocaleString();} catch { /* silent */ }}@@@ -654,7 +654,9 @@ async function sanitizeUser(user, publicOnly = false) {// Under BILLING_AUTHORITY=billing the column is a frozen legacy copy (ADR-012 rule 8: never// shown as live); the balance comes from /api/funds/balance.if (!require('../monetization/money-authority').onBilling()) safe.openvibe_bucks_balance = user.openvibe_bucks_balance;-- safe.openvibe_coins_balance = user.openvibe_coins_balance;++ // Kept as null for one release: the previous release's pages expect the field in /api/auth/me (test/n-1.test.js).++ // OpenCoins come from /api/coins/balance (the Network wallet); the frozen column is never read. Goes with the column.++ safe.openvibe_coins_balance = null;safe.stream_key = user.stream_key;}return safe;@@@ -115,7 +115,7 @@ contextRouter.get('/users/profile', async (req, res) => {if (!user) return fail(res, 404, 'User not found');const profile = await db.getUserProfile(user.id);if (!profile) return fail(res, 404, 'Profile not found');-- // OpenCoins are part of the game; Vibes (real money) and presence stay with the user.++ // Vibes (real money) and presence stay with the user. No OpenCoins: they are each person's Network wallet.const viewerId = int(req.query.viewer_id, 0);if (!viewerId || viewerId !== user.id) {delete profile.openvibe_bucks_balance;@@@ -2162,7 +2162,7 @@ async function countEasterEggSolves(eggDate) {async function getUserProfile(userId) {const user = await get(`SELECT id, username, display_name, avatar_url, profile_color, role,-- openvibe_bucks_balance, openvibe_coins_balance, created_at, last_seen++ openvibe_bucks_balance, created_at, last_seenFROM users WHERE id = ?`, [userId]);if (!user) return null;// The user's chat total is OpenVibe.Chat's; a synchronous peek answers the last good count, else@@@ -2972,19 +2972,6 @@ async function deleteChannelAiBot(id) {// ── OpenCoins helpers ───────────────────────────────────────--async function addOpenCoins(userId, amount) {-- return await run(`UPDATE users SET openvibe_coins_balance = openvibe_coins_balance + ? WHERE id = ?`,-- [amount, userId]);--}----async function deductOpenCoins(userId, amount) {-- const result = await run(-- `UPDATE users SET openvibe_coins_balance = openvibe_coins_balance - ? WHERE id = ? AND openvibe_coins_balance >= ?`,-- [amount, userId, amount]-- );-- return result.changes > 0;--}--async function createCoinTransaction({ user_id, stream_id, amount, type, reward_id, message }) {return await run(`INSERT INTO coin_transactions (user_id, stream_id, amount, type, reward_id, message)@@@ -3930,7 +3917,7 @@ module.exports = {// Transactions (Vibes)createTransaction, addVibes, deductVibes, addVibesCashout, deductVibesCashout,// OpenCoins-- addOpenCoins, deductOpenCoins, createCoinTransaction, getCoinTransactions,++ createCoinTransaction, getCoinTransactions,// Coin RewardscreateCoinReward, getCoinRewardsByStreamer, getCoinRewardById, updateCoinReward, deleteCoinReward,// Coin Redemptions@@@ -9,8 +9,8 @@** 2. OPENCOINS (network-wide wallet) — the OpenVibe.Network-owned balance shared by* Live/Games/Tools. All reads and earn/spend go through the Network wallet API-- * (see wallet-client.js); the legacy users.openvibe_coins_balance column is-- * frozen for the migration script and is never written anymore.++ * (see wallet-client.js). Live keeps no copy: the legacy users.openvibe_coins_balance column is++ * read and written by nothing and goes in a contract migration.** Every channel-points debit and credit carries a deterministic per-event key (ADR-012 rule 5,* db.applyChannelPoints): watch = the watch_time row + minute, chat = the user's minute, follow =@@@ -286,15 +286,12 @@ class OpenCoins {}/**-- * The network-wide OpenCoins wallet balance (OpenVibe.Network-owned).-- * Reads via the wallet API using the caller's Network JWT; falls back to the-- * frozen legacy column only when the wallet is unreachable/unlinked.++ * The network-wide OpenCoins wallet balance (OpenVibe.Network-owned), read with the caller's Network JWT.++ * null when the wallet cannot answer (unreachable, or no Network account): the page says so instead of++ * showing a number that is not the balance.*/-- async getGold(userId, userToken = null) {-- const balance = await wallet.balanceForToken(userToken);-- if (balance !== null) return balance;-- const user = await db.getUserById(userId);-- return user ? (user.openvibe_coins_balance || 0) : 0;++ async getGold(_userId, userToken = null) {++ return await wallet.balanceForToken(userToken);}/** Server-side earn/spend passthroughs (idempotency keys: `live:<event>:<id>`). */@@@ -2,9 +2,9 @@* OpenVibe.Live — OpenCoins wallet client (OpenVibe.Network-owned)** The network-wide OpenCoins wallet lives in OpenVibe.Network (see CONTRACTS.md,-- * "OpenCoins wallet"). Live's legacy local balance column-- * (users.openvibe_coins_balance) is frozen for the migration script; every earn/-- * spend goes through this client now.++ * "OpenCoins wallet"). Live keeps no balance of its own: every read, earn and spend++ * goes through this client (the legacy users.openvibe_coins_balance column is read by++ * nothing; test/coins-column-unread.test.js keeps it that way until it is dropped).** POST /internal/coins/credit|debit|transfer (Live's service token, server-to-server)* GET /api/coins/me, /api/coins/me/history (Bearer user JWT, read-side)nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++++// OpenCoins are each person's OpenVibe.Network wallet (server/monetization/wallet-client.js). Live's legacy++// users.openvibe_coins_balance column is read and written by nothing under server/, scripts/ or public/ (no stale++// fallback, no copy sent to a page), so the contract migration that drops it breaks nothing. The one mention allowed is++// the public serializer's list of fields that must never leave the server.++++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const ROOT = path.join(__dirname, '..');++const NAME = /\bopenvibe_coins_balance\b/;++const ALLOWED = new Set(['server/web/serializers.js']);++// The one write the previous release needs: /api/auth/me keeps the field, as null, for one release (test/n-1.test.js).++const NULL_SHAPE = /\bopenvibe_coins_balance\s*=\s*null\s*;/;++const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);++++function walk(dir, out = []) {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const p = path.join(dir, e.name);++ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'vendor') walk(p, out); } else if (e.name.endsWith('.js')) out.push(p);++ }++ return out;++}++++const offenders = [];++for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts')), ...walk(path.join(ROOT, 'public', 'js'))]) {++ const rel = path.relative(ROOT, file);++ if (ALLOWED.has(rel)) continue;++ fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => {++ if (!isCommentLine(line) && NAME.test(line) && !NULL_SHAPE.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);++ });++}++assert.deepStrictEqual(offenders, [], 'OpenCoins come from the Network wallet (wallet-client.js / opencoins.getGold), never users.openvibe_coins_balance');++console.log('coins column: nothing reads users.openvibe_coins_balance');
@@ -768,12 +768,13 @@ async function loadBalance() {
@@ -768,12 +768,13 @@ async function loadBalance() {
// Navbar OpenCoins = the GLOBAL currency (game / cosmetics / media wallet).
// Navbar OpenCoins = the GLOBAL currency (game / cosmetics / media wallet).
try {
try {
const coinData = await api('/coins/balance');
const coinData = await api('/coins/balance');
- const coins = coinData.balance || 0;
+ // null: the Network wallet did not answer. Say so ("—") rather than show 0 as the balance.
+ const coins = coinData.balance == null ? '—' : Number(coinData.balance).toLocaleString();
const coinEl = document.getElementById('nav-coins-amount');
const coinEl = document.getElementById('nav-coins-amount');
- if (coinEl) coinEl.textContent = coins.toLocaleString();
- if (window.LiveNav) window.LiveNav.setChip('coins', coins.toLocaleString());
+ if (coinEl) coinEl.textContent = coins;
+ if (window.LiveNav) window.LiveNav.setChip('coins', coins);
const udC = document.getElementById('ud-coins');
const udC = document.getElementById('ud-coins');
- if (udC) udC.textContent = coins.toLocaleString();
+ if (udC) udC.textContent = coins;
} catch { /* silent */ }
} catch { /* silent */ }
}
}
@@ -461,7 +461,6 @@
@@ -461,7 +461,6 @@
</div>
</div>
</div>`;
</div>`;
}
}
- const coins = p.openvibe_coins_balance || 0;
// Chat answers the message count; a cold cache leaves it null and the card omits the stat
// Chat answers the message count; a cold cache leaves it null and the card omits the stat
// rather than showing a stale mirror number as real.
// rather than showing a stale mirror number as real.
const msgs = p.messageCount == null ? null : Number(p.messageCount);
const msgs = p.messageCount == null ? null : Number(p.messageCount);
@@ -479,7 +478,6 @@
@@ -479,7 +478,6 @@
</div>
</div>
</div>
</div>
<div class="ctx-stats">
<div class="ctx-stats">
- <div class="ctx-stat"><i class="fa-solid fa-coins"></i> ${numf(coins)}</div>
${msgs == null ? '' : `<div class="ctx-stat"><i class="fa-solid fa-message"></i> ${numf(msgs)}</div>`}
${msgs == null ? '' : `<div class="ctx-stat"><i class="fa-solid fa-message"></i> ${numf(msgs)}</div>`}
<div class="ctx-stat"><i class="fa-solid fa-heart"></i> ${numf(p.followerCount || 0)}</div>
<div class="ctx-stat"><i class="fa-solid fa-heart"></i> ${numf(p.followerCount || 0)}</div>
</div>
</div>
@@ -1163,8 +1163,7 @@ async function loadDashCoins() {
@@ -1163,8 +1163,7 @@ async function loadDashCoins() {
if (!currentUser) return;
if (!currentUser) return;
try {
try {
const data = await api('/coins/balance');
const data = await api('/coins/balance');
- const bal = data.balance || 0;
- document.getElementById('dash-coins-amount').textContent = bal.toLocaleString();
+ document.getElementById('dash-coins-amount').textContent = data.balance == null ? '—' : Number(data.balance).toLocaleString();
} catch { /* silent */ }
} catch { /* silent */ }
}
}
@@ -654,7 +654,9 @@ async function sanitizeUser(user, publicOnly = false) {
@@ -654,7 +654,9 @@ async function sanitizeUser(user, publicOnly = false) {
// Under BILLING_AUTHORITY=billing the column is a frozen legacy copy (ADR-012 rule 8: never
// Under BILLING_AUTHORITY=billing the column is a frozen legacy copy (ADR-012 rule 8: never
// shown as live); the balance comes from /api/funds/balance.
// shown as live); the balance comes from /api/funds/balance.
if (!require('../monetization/money-authority').onBilling()) safe.openvibe_bucks_balance = user.openvibe_bucks_balance;
if (!require('../monetization/money-authority').onBilling()) safe.openvibe_bucks_balance = user.openvibe_bucks_balance;
- safe.openvibe_coins_balance = user.openvibe_coins_balance;
+ // Kept as null for one release: the previous release's pages expect the field in /api/auth/me (test/n-1.test.js).
+ // OpenCoins come from /api/coins/balance (the Network wallet); the frozen column is never read. Goes with the column.
+ safe.openvibe_coins_balance = null;
safe.stream_key = user.stream_key;
safe.stream_key = user.stream_key;
}
}
return safe;
return safe;
@@ -115,7 +115,7 @@ contextRouter.get('/users/profile', async (req, res) => {
@@ -115,7 +115,7 @@ contextRouter.get('/users/profile', async (req, res) => {
if (!user) return fail(res, 404, 'User not found');
if (!user) return fail(res, 404, 'User not found');
const profile = await db.getUserProfile(user.id);
const profile = await db.getUserProfile(user.id);
if (!profile) return fail(res, 404, 'Profile not found');
if (!profile) return fail(res, 404, 'Profile not found');
- // OpenCoins are part of the game; Vibes (real money) and presence stay with the user.
+ // Vibes (real money) and presence stay with the user. No OpenCoins: they are each person's Network wallet.
const viewerId = int(req.query.viewer_id, 0);
const viewerId = int(req.query.viewer_id, 0);
if (!viewerId || viewerId !== user.id) {
if (!viewerId || viewerId !== user.id) {
delete profile.openvibe_bucks_balance;
delete profile.openvibe_bucks_balance;
@@ -2162,7 +2162,7 @@ async function countEasterEggSolves(eggDate) {
@@ -2162,7 +2162,7 @@ async function countEasterEggSolves(eggDate) {
async function getUserProfile(userId) {
async function getUserProfile(userId) {
const user = await get(`SELECT id, username, display_name, avatar_url, profile_color, role,
const user = await get(`SELECT id, username, display_name, avatar_url, profile_color, role,
- openvibe_bucks_balance, openvibe_coins_balance, created_at, last_seen
+ openvibe_bucks_balance, created_at, last_seen
FROM users WHERE id = ?`, [userId]);
FROM users WHERE id = ?`, [userId]);
if (!user) return null;
if (!user) return null;
// The user's chat total is OpenVibe.Chat's; a synchronous peek answers the last good count, else
// The user's chat total is OpenVibe.Chat's; a synchronous peek answers the last good count, else
@@ -2972,19 +2972,6 @@ async function deleteChannelAiBot(id) {
@@ -2972,19 +2972,6 @@ async function deleteChannelAiBot(id) {
// ── OpenCoins helpers ───────────────────────────────────────
// ── OpenCoins helpers ───────────────────────────────────────
-async function addOpenCoins(userId, amount) {
- return await run(`UPDATE users SET openvibe_coins_balance = openvibe_coins_balance + ? WHERE id = ?`,
- [amount, userId]);
-}
-
-async function deductOpenCoins(userId, amount) {
- const result = await run(
- `UPDATE users SET openvibe_coins_balance = openvibe_coins_balance - ? WHERE id = ? AND openvibe_coins_balance >= ?`,
- [amount, userId, amount]
- );
- return result.changes > 0;
-}
-
async function createCoinTransaction({ user_id, stream_id, amount, type, reward_id, message }) {
async function createCoinTransaction({ user_id, stream_id, amount, type, reward_id, message }) {
return await run(
return await run(
`INSERT INTO coin_transactions (user_id, stream_id, amount, type, reward_id, message)
`INSERT INTO coin_transactions (user_id, stream_id, amount, type, reward_id, message)
@@ -3930,7 +3917,7 @@ module.exports = {
@@ -3930,7 +3917,7 @@ module.exports = {
// Transactions (Vibes)
// Transactions (Vibes)
createTransaction, addVibes, deductVibes, addVibesCashout, deductVibesCashout,
createTransaction, addVibes, deductVibes, addVibesCashout, deductVibesCashout,
// OpenCoins
// OpenCoins
- addOpenCoins, deductOpenCoins, createCoinTransaction, getCoinTransactions,
+ createCoinTransaction, getCoinTransactions,
// Coin Rewards
// Coin Rewards
createCoinReward, getCoinRewardsByStreamer, getCoinRewardById, updateCoinReward, deleteCoinReward,
createCoinReward, getCoinRewardsByStreamer, getCoinRewardById, updateCoinReward, deleteCoinReward,
// Coin Redemptions
// Coin Redemptions
@@ -9,8 +9,8 @@
@@ -9,8 +9,8 @@
*
*
* 2. OPENCOINS (network-wide wallet) — the OpenVibe.Network-owned balance shared by
* 2. OPENCOINS (network-wide wallet) — the OpenVibe.Network-owned balance shared by
* Live/Games/Tools. All reads and earn/spend go through the Network wallet API
* Live/Games/Tools. All reads and earn/spend go through the Network wallet API
- * (see wallet-client.js); the legacy users.openvibe_coins_balance column is
- * frozen for the migration script and is never written anymore.
+ * (see wallet-client.js). Live keeps no copy: the legacy users.openvibe_coins_balance column is
+ * read and written by nothing and goes in a contract migration.
*
*
* Every channel-points debit and credit carries a deterministic per-event key (ADR-012 rule 5,
* Every channel-points debit and credit carries a deterministic per-event key (ADR-012 rule 5,
* db.applyChannelPoints): watch = the watch_time row + minute, chat = the user's minute, follow =
* db.applyChannelPoints): watch = the watch_time row + minute, chat = the user's minute, follow =
@@ -286,15 +286,12 @@ class OpenCoins {
@@ -286,15 +286,12 @@ class OpenCoins {
}
}
/**
/**
- * The network-wide OpenCoins wallet balance (OpenVibe.Network-owned).
- * Reads via the wallet API using the caller's Network JWT; falls back to the
- * frozen legacy column only when the wallet is unreachable/unlinked.
+ * The network-wide OpenCoins wallet balance (OpenVibe.Network-owned), read with the caller's Network JWT.
+ * null when the wallet cannot answer (unreachable, or no Network account): the page says so instead of
+ * showing a number that is not the balance.
*/
*/
- async getGold(userId, userToken = null) {
- const balance = await wallet.balanceForToken(userToken);
- if (balance !== null) return balance;
- const user = await db.getUserById(userId);
- return user ? (user.openvibe_coins_balance || 0) : 0;
+ async getGold(_userId, userToken = null) {
+ return await wallet.balanceForToken(userToken);
}
}
/** Server-side earn/spend passthroughs (idempotency keys: `live:<event>:<id>`). */
/** Server-side earn/spend passthroughs (idempotency keys: `live:<event>:<id>`). */
@@ -2,9 +2,9 @@
@@ -2,9 +2,9 @@
* OpenVibe.Live — OpenCoins wallet client (OpenVibe.Network-owned)
* OpenVibe.Live — OpenCoins wallet client (OpenVibe.Network-owned)
*
*
* The network-wide OpenCoins wallet lives in OpenVibe.Network (see CONTRACTS.md,
* The network-wide OpenCoins wallet lives in OpenVibe.Network (see CONTRACTS.md,
- * "OpenCoins wallet"). Live's legacy local balance column
- * (users.openvibe_coins_balance) is frozen for the migration script; every earn/
- * spend goes through this client now.
+ * "OpenCoins wallet"). Live keeps no balance of its own: every read, earn and spend
+ * goes through this client (the legacy users.openvibe_coins_balance column is read by
+ * nothing; test/coins-column-unread.test.js keeps it that way until it is dropped).
*
*
* POST /internal/coins/credit|debit|transfer (Live's service token, server-to-server)
* POST /internal/coins/credit|debit|transfer (Live's service token, server-to-server)
* GET /api/coins/me, /api/coins/me/history (Bearer user JWT, read-side)
* GET /api/coins/me, /api/coins/me/history (Bearer user JWT, read-side)
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+
+// OpenCoins are each person's OpenVibe.Network wallet (server/monetization/wallet-client.js). Live's legacy
+// users.openvibe_coins_balance column is read and written by nothing under server/, scripts/ or public/ (no stale
+// fallback, no copy sent to a page), so the contract migration that drops it breaks nothing. The one mention allowed is
+// the public serializer's list of fields that must never leave the server.
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const NAME = /\bopenvibe_coins_balance\b/;
+const ALLOWED = new Set(['server/web/serializers.js']);
+// The one write the previous release needs: /api/auth/me keeps the field, as null, for one release (test/n-1.test.js).
+const NULL_SHAPE = /\bopenvibe_coins_balance\s*=\s*null\s*;/;
+const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);
+
+function walk(dir, out = []) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'vendor') walk(p, out); } else if (e.name.endsWith('.js')) out.push(p);
+ }
+ return out;
+}
+
+const offenders = [];
+for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts')), ...walk(path.join(ROOT, 'public', 'js'))]) {
+ const rel = path.relative(ROOT, file);
+ if (ALLOWED.has(rel)) continue;
+ fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
+ if (!isCommentLine(line) && NAME.test(line) && !NULL_SHAPE.test(line)) offenders.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
+ });
+}
+assert.deepStrictEqual(offenders, [], 'OpenCoins come from the Network wallet (wallet-client.js / opencoins.getGold), never users.openvibe_coins_balance');
+console.log('coins column: nothing reads users.openvibe_coins_balance');