openvibers/openvibe.live Commits

PowerChat: request the platform scopes, send required ids, full test menu

OP OpenVibers committed 26 Aug 2026 · ab9c6267e5 · 11 files, +363 −46

The overlay showed 0 viewers because the seeded scope list only carried the
integration scopes — grants mint exactly what /oauth/authorize REQUESTS, so
every platform push (view count, chat, follows, currency) 403'd. Widen the
seed + migrate old defaults, and surface missing scopes as a reconnect
prompt on the dashboard card. POST /chat also never sent the required
messageId (400 on every relay); follows/currency now always send their
required externalId. New: subs and Vibes donations forward to PowerChat
(subscriptions:write / tips:write via openvibe_vibes @ 100/USD), webhook
handler skips isTest and our own developer_app echoes, and the test button
is now a menu that fires fake tips/subs/follows/points/host/chat/view-count.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex cfd59c38..0adbf1df 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -5382,6 +5382,30 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.pc-btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
.pc-btn-row .btn { flex: 0 1 auto; }
++/* "Test on PowerChat" dropdown menu */
++.pc-test-menu-wrap { position: relative; display: inline-block; }
++.pc-test-caret { font-size: 0.7em; margin-left: 2px; transition: transform .18s ease; }
++.pc-test-menu-wrap.open .pc-test-caret { transform: rotate(180deg); }
++.pc-test-menu {
++ position: absolute; top: calc(100% + 6px); left: 0; z-index: 40; min-width: 230px;
++ background: var(--bg-secondary, #17171d); border: 1px solid var(--border-color, #2a2a33);
++ border-radius: 10px; padding: 6px; box-shadow: 0 10px 30px rgba(0,0,0,0.45);
++ display: flex; flex-direction: column; gap: 2px; animation: pcFadeIn .16s ease-out;
++}
++.pc-test-menu-head {
++ font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em;
++ color: var(--text-muted, #888); padding: 5px 9px 6px;
++ border-bottom: 1px solid var(--border-color, #2a2a33); margin-bottom: 3px;
++}
++.pc-test-menu button {
++ display: flex; align-items: center; gap: 9px; width: 100%; text-align: left;
++ background: none; border: none; color: var(--text, #ddd); font-size: 0.85rem;
++ padding: 7px 9px; border-radius: 7px; cursor: pointer;
++}
++.pc-test-menu button:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
++.pc-test-menu button:disabled { opacity: .55; cursor: default; }
++.pc-test-menu button i { width: 16px; text-align: center; color: var(--accent, #d8b98a); font-size: 0.8rem; }
++
/* Connecting box — centered, roomy, branded */
.pc-status.pc-status-block { display: block; }
.pc-connecting-box {
ddiff --git a/public/js/powerchat.js b/public/js/powerchat.js
iindex ed674669..a9057a0a 100644
---- a/public/js/powerchat.js
++++ b/public/js/powerchat.js
@@@ -49,7 +49,19 @@
<span class="pc-action-label"><i class="fa-solid fa-flask"></i> Test your setup</span>
<div class="pc-btn-row">
<button class="btn btn-primary btn-small" onclick="powerchatTestTip(this)"><i class="fa-solid fa-gift"></i> Send test tip</button>
-- <button class="btn btn-outline btn-small" onclick="powerchatTestAlert(this)"><i class="fa-solid fa-bell"></i> Test overlay alert</button>
++ <div class="pc-test-menu-wrap">
++ <button class="btn btn-outline btn-small" onclick="powerchatToggleTestMenu(event)"><i class="fa-solid fa-bell"></i> Test on PowerChat <i class="fa-solid fa-caret-down pc-test-caret"></i></button>
++ <div class="pc-test-menu" hidden>
++ <div class="pc-test-menu-head">Fires a fake event on your PowerChat overlay</div>
++ <button onclick="powerchatTestKind('tip', this)"><i class="fa-solid fa-hand-holding-dollar"></i> Tip alert</button>
++ <button onclick="powerchatTestKind('subscribe', this)"><i class="fa-solid fa-star"></i> Subscription alert</button>
++ <button onclick="powerchatTestKind('follow', this)"><i class="fa-solid fa-user-plus"></i> Follow alert</button>
++ <button onclick="powerchatTestKind('channel_points', this)"><i class="fa-solid fa-coins"></i> Channel-points redeem</button>
++ <button onclick="powerchatTestKind('host', this)"><i class="fa-solid fa-tower-broadcast"></i> Host alert</button>
++ <button onclick="powerchatTestKind('chat', this)"><i class="fa-solid fa-comment"></i> Chat message</button>
++ <button onclick="powerchatTestKind('view-count', this)"><i class="fa-solid fa-eye"></i> Viewer-count chip</button>
++ </div>
++ </div>
</div>
</div>
<div class="pc-action-group">
@@@ -59,7 +71,22 @@
<button class="btn btn-outline btn-small pc-disconnect-btn" onclick="powerchatDisconnect(this)"><i class="fa-solid fa-link-slash"></i> Disconnect</button>
</div>
</div>`;
-- if (hintEl) hintEl.textContent = st.last_error ? ('Note: ' + st.last_error) : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.';
++ // A grant never gains scopes retroactively — if the app now asks for more than
++ // this connection was minted with (e.g. viewer count / chat / tips were added
++ // later), the streamer must re-consent. Without this, those features silently 403.
++ const missing = Array.isArray(st.missing_scopes) ? st.missing_scopes : [];
++ if (missing.length) {
++ if (actionsEl) actionsEl.insertAdjacentHTML('afterbegin', `
++ <div class="pc-action-group">
++ <span class="pc-action-label pc-warn"><i class="fa-solid fa-triangle-exclamation"></i> New permissions available</span>
++ <div class="pc-btn-row">
++ <button class="btn btn-primary btn-small" onclick="powerchatConnect()"><i class="fa-solid fa-rotate"></i> Reconnect to enable</button>
++ </div>
++ </div>`);
++ if (hintEl) hintEl.innerHTML = `<span class="pc-warn">Your connection is missing: ${esc(missing.join(', '))}. Reconnect (one click, same account) to enable viewer count, chat, and alerts on your PowerChat overlay.</span>`;
++ } else if (hintEl) {
++ hintEl.textContent = st.last_error ? ('Note: ' + st.last_error) : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.';
++ }
} else {
_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');
if (actionsEl) actionsEl.innerHTML = `<button class="btn btn-primary btn-small pc-connect-btn" onclick="powerchatConnect()"><i class="fa-solid fa-plug"></i> Connect PowerChat</button>`;
@@@ -215,16 +242,47 @@
finally { if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-gift"></i> Send test tip'; } }
};
-- // PowerChat's own overlay test alert (requires the alerts:trigger scope).
-- window.powerchatTestAlert = async function powerchatTestAlert(btn) {
-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }
-- try { await api('/powerchat/test-alert', { method: 'POST' }); toast('Overlay test alert sent to PowerChat ✓', 'success'); }
-- catch (e) {
-- const m = (e && e.message) || '';
-- if (/reconnect/i.test(m) || /alerts:trigger/i.test(m)) toast('Reconnect PowerChat to enable overlay test alerts (a new permission was added).', 'warning');
-- else toast('Overlay alert failed: ' + (m || 'error'), 'error');
++ // ── "Test on PowerChat" dropdown — fire a fake event of any kind ──────────
++ function _closeTestMenus() {
++ document.querySelectorAll('.pc-test-menu').forEach(m => { m.hidden = true; });
++ document.querySelectorAll('.pc-test-menu-wrap.open').forEach(w => w.classList.remove('open'));
++ }
++ window.powerchatToggleTestMenu = function powerchatToggleTestMenu(ev) {
++ if (ev) { ev.preventDefault(); ev.stopPropagation(); }
++ const wrap = ev && ev.currentTarget ? ev.currentTarget.closest('.pc-test-menu-wrap') : null;
++ const menu = wrap && wrap.querySelector('.pc-test-menu');
++ if (!menu) return;
++ const wasHidden = menu.hidden;
++ _closeTestMenus();
++ if (wasHidden) { menu.hidden = false; wrap.classList.add('open'); }
++ };
++ document.addEventListener('click', (e) => {
++ if (!e.target.closest || !e.target.closest('.pc-test-menu-wrap')) _closeTestMenus();
++ });
++
++ // Fire one fake event on the PowerChat overlay. Alert kinds are display-only test
++ // alerts (never credit anything); chat/view-count go through the real intake so the
++ // streamer sees exactly what the live relay produces.
++ window.powerchatTestKind = async function powerchatTestKind(kind, btn) {
++ const menu = btn && btn.closest('.pc-test-menu');
++ const buttons = menu ? Array.from(menu.querySelectorAll('button')) : (btn ? [btn] : []);
++ buttons.forEach(b => { b.disabled = true; });
++ const orig = btn ? btn.innerHTML : '';
++ if (btn) btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…';
++ try {
++ const r = await api('/powerchat/test-alert', { method: 'POST', body: { kind } });
++ toast((r && r.note) || 'Test sent to PowerChat ✓', 'success');
++ _closeTestMenus();
++ } catch (e) {
++ // api() throws { message: <error code>, data: <body> } — the human-readable
++ // text for scope failures is in data.message.
++ const m = (e && e.data && e.data.message) || (e && e.message) || '';
++ if (/reconnect/i.test(m)) toast(m, 'warning');
++ else toast('Test failed: ' + (m || 'error'), 'error');
++ } finally {
++ if (btn) btn.innerHTML = orig;
++ buttons.forEach(b => { b.disabled = false; });
}
-- finally { if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-bell"></i> Test overlay alert'; } }
};
// Chain into the dashboard load.
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 2467002e..a7f6819e 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -906,11 +906,16 @@ class ChatServer {
// AI viewers genuinely need a live stream.
try {
if (client.channelUserId) {
++ let isMod = false, isSub = false;
++ try { isMod = !!(client.user && permissions.canModerateChannel(client.user, client.channelUserId)); } catch { /* */ }
++ try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }
require('../integrations/powerchat-platform').forwardChat(client.channelUserId, {
chatterName: username,
externalChatterId: client.user?.id ? ('u' + client.user.id) : ('a' + (client.anonId || 'anon')),
message: text,
avatarUrl: client.user?.avatar_url || undefined,
++ isModerator: isMod,
++ isSubscriber: isSub,
});
}
} catch { /* non-critical */ }
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 6c679197..7b703f3c 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1034,11 +1034,25 @@ function initDb() {
['powerchat_client_id', '', 'PowerChat OAuth client_id (pca_…) from the PowerChat Developer dashboard', 'string'],
['powerchat_client_secret', '', 'PowerChat OAuth client_secret (pcs_…) — shown once; owner-only', 'string'],
['powerchat_webhook_secret', '', 'PowerChat webhook signing secret (pcw_…) — shown once; owner-only', 'string'],
-- ['powerchat_scopes', 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger', 'OAuth scopes requested from each streamer (space-delimited)', 'string'],
-- ['powerchat_sandbox_username', 'n8admin', 'Sandbox streamer username the app can act on until approved (the app owner’s PowerChat username)', 'string'],
++ // The scope list MUST include the platform scopes (chat:write, viewcount:write,
++ // subscriptions:write, follows:write, currency:write, tips:write) — PowerChat
++ // grants exactly what /oauth/authorize REQUESTS, so a narrow list here means
++ // every platform push (viewer count, chat, …) 403s even though the app
++ // registration has the scopes.
++ ['powerchat_scopes', 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write', 'OAuth scopes requested from each streamer (space-delimited)', 'string'],
++ ['powerchat_sandbox_username', 'alex', 'Sandbox streamer username the app can act on until approved (the app owner’s PowerChat username)', 'string'],
];
const seedPc = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");
for (const [k, v, d, t] of powerchatSeeds) seedPc.run(k, v, d, t);
++ // Upgrade rows still sitting on an OLD seeded default (admin never customized them).
++ // The original seed lacked every platform scope, which is why platform pushes 403'd;
++ // a later interim default lacked tips:write. Custom values are left untouched.
++ const fullScopes = powerchatSeeds.find(r => r[0] === 'powerchat_scopes')[1];
++ database.prepare(`UPDATE site_settings SET value = ? WHERE key = 'powerchat_scopes' AND value IN (?, ?)`)
++ .run(fullScopes,
++ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger',
++ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write');
++ database.prepare(`UPDATE site_settings SET value = 'alex' WHERE key = 'powerchat_sandbox_username' AND value = 'n8admin'`).run();
} catch (e) { console.warn('[DB] Settings seed:', e.message); }
// AI subsystem tables + columns.
ddiff --git a/server/integrations/powerchat-oauth.js b/server/integrations/powerchat-oauth.js
iindex b765255f..a02897c6 100644
---- a/server/integrations/powerchat-oauth.js
++++ b/server/integrations/powerchat-oauth.js
@@@ -37,11 +37,14 @@ function getConfig() {
clientSecret: s('powerchat_client_secret'),
webhookSecret: s('powerchat_webhook_secret'),
// Request the integration scopes AND the platform scopes we actually use
-- // (chat:write / viewcount:write / subscriptions:write / currency:write), so the
-- // grant carries them — otherwise every platform intake call 403s. Widening the
-- // default requires the streamer to reconnect (re-consent) to re-mint tokens.
-- scopes: s('powerchat_scopes') || 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write',
-- sandboxUsername: s('powerchat_sandbox_username') || 'n8admin',
++ // (chat:write / viewcount:write / subscriptions:write / follows:write /
++ // currency:write / tips:write), so the grant carries them — otherwise every
++ // platform intake call 403s. NOTE: the DB seeds this setting, so the seeded
++ // value in database.js is what actually applies — keep the two lists in sync.
++ // Widening the list requires the streamer to reconnect (re-consent) to re-mint
++ // tokens with the wider set.
++ scopes: s('powerchat_scopes') || 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write',
++ sandboxUsername: s('powerchat_sandbox_username') || 'alex',
authorizeUrl: `${baseUrl}/oauth/authorize`,
tokenUrl: `${baseUrl}/oauth/token`,
revokeUrl: `${baseUrl}/oauth/revoke`,
@@@ -234,7 +237,10 @@ async function getValidAccessToken(userId, { force = false } = {}) {
}
throw err;
}
-- // Persist the NEW pair atomically before using it.
++ // Persist the NEW pair atomically before using it. PowerChat rotates the refresh
++ // token on every use, but defend against a response that omits it — writing null
++ // would strand the connection with no way to refresh.
++ if (!t.refresh_token) t.refresh_token = conn.refresh_token;
db.updatePowerchatTokens(userId, t);
return t.access_token;
}
ddiff --git a/server/integrations/powerchat-platform.js b/server/integrations/powerchat-platform.js
iindex ed716e7f..dc522276 100644
---- a/server/integrations/powerchat-platform.js
++++ b/server/integrations/powerchat-platform.js
@@@ -3,20 +3,31 @@
*
* When a streamer has connected PowerChat with the relevant scopes, OpenVibe.Live acts
* like a streaming platform feeding PowerChat's unified overlays:
-- * - chat:write → real OpenVibe.Live chat merges into the unified chat overlay
-- * - viewcount:write → OpenVibe.Live's viewer count shows as its own branded chip
-- * - currency:write → channel-point redemptions fire PowerChat alerts + leaderboards
++ * - chat:write → real OpenVibe.Live chat merges into the unified chat overlay
++ * - viewcount:write → OpenVibe.Live's viewer count shows as its own branded chip
++ * - follows:write → new follows fire PowerChat follow alerts + follow goals
++ * - subscriptions:write → paid channel subs fire sub alerts + credit goals/subathon
++ * - currency:write → channel-point redemptions fire PowerChat alerts + leaderboards
++ * - tips:write → on-site Vibes donations fire PowerChat tip alerts + credit
++ * tip goals/totals (Vibes are bit-style: 100 = $1, so the
++ * declared currency carries unitsPerUsd=100)
*
* Everything is best-effort and scope-gated: a missing scope / sandbox restriction just
-- * makes the call a no-op (PowerChat also checks scopes live and 403s, which we swallow).
++ * makes the call a no-op (PowerChat also checks scopes live and 403s, which we surface
++ * on the connection's last_error so the dashboard can prompt a reconnect).
*/
'use strict';
++const crypto = require('crypto');
const db = require('../db/database');
const oauth = require('./powerchat-oauth');
const config = require('../config');
--// The virtual-currency key the app declares in the PowerChat dashboard for channel points.
++// Virtual-currency keys the app declares in the PowerChat dashboard.
++// - openvibe_points: points-only (no USD rate) — channel-point redemptions.
++// - openvibe_vibes: monetary — MUST be declared with unitsPerUsd=100 (Vibes are
++// bit-style, 100 Vibes = $1) or POST /tips 400s with "no USD rate".
const CURRENCY_KEY = 'openvibe_points';
++const TIP_CURRENCY_KEY = 'openvibe_vibes';
// PowerChat's /chat avatarUrl only renders when it's a full absolute http(s) URL — relative
// paths, data:/blob:, or bare values are silently treated as "no avatar" (an initial-letter
@@@ -50,14 +61,38 @@ function _connFor(userId, scopeNeeded) {
if (!oauth.getConfig().enabled) return null;
const conn = db.getPowerchatConnection(userId);
if (!conn || !conn.access_token || !conn.refresh_token) return null;
-- if (scopeNeeded && conn.scope && !String(conn.scope).split(/\s+/).includes(scopeNeeded)) return null;
++ if (scopeNeeded && conn.scope && !String(conn.scope).split(/\s+/).includes(scopeNeeded)) {
++ // The grant was minted without this scope (classic "registered but not
++ // requested"). Surface it once so the dashboard can prompt a reconnect —
++ // silently skipping here is exactly how "0 viewers on the overlay" happens.
++ _noteScopeGap(userId, scopeNeeded);
++ return null;
++ }
return conn;
} catch { return null; }
}
++// Record a missing-scope condition on the connection at most once per hour per scope,
++// so /status can say "reconnect to grant X" instead of the relay failing invisibly.
++const _scopeGapNoted = new Map(); // `${userId}:${scope}` → notedAt
++function _noteScopeGap(userId, scope) {
++ const key = `${userId}:${scope}`;
++ const now = Date.now();
++ if ((_scopeGapNoted.get(key) || 0) > now - 3600000) return;
++ _scopeGapNoted.set(key, now);
++ console.warn(`[PowerChat] user ${userId}'s grant is missing scope ${scope} — reconnect (re-consent) required for that feature`);
++ try { db.setPowerchatConnectionError(userId, `Reconnect needed: your PowerChat grant is missing the "${scope}" permission`); } catch { /* */ }
++}
++
++// A 403 from PowerChat means the scope was granted but later disabled/shrunk by the
++// streamer (or sandbox restriction). Same remedy: surface it, rate-limited.
++function _noteApiError(userId, scope, err) {
++ if (err && err.status === 403) _noteScopeGap(userId, `${scope} (disabled on PowerChat)`);
++}
++
// ── chat:write ───────────────────────────────────────────────
const _chatBuckets = new Map(); // userId → { count, resetAt } (~120/min limit; we cap at 100)
--async function forwardChat(streamerUserId, { chatterName, externalChatterId, message, avatarUrl, isModerator } = {}) {
++async function forwardChat(streamerUserId, { chatterName, externalChatterId, message, messageId, avatarUrl, isModerator, isSubscriber } = {}) {
if (!message || !chatterName) return;
const conn = _connFor(streamerUserId, 'chat:write');
if (!conn) return;
@@@ -72,16 +107,22 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
method: 'POST', path: '/chat',
// Field caps match the PowerChat schema (chatterName 1-48, externalChatterId
// 1-128, message 1-500) — over-long values 400 with VALIDATION_ERROR.
++ // messageId is REQUIRED (idempotency key): a body without it is a 400, so
++ // every relay silently failed before this was sent. OpenVibe chat has no
++ // retry path, so a generated UUID is a valid one-shot key.
body: {
chatterName: String(chatterName).slice(0, 48),
externalChatterId: String(externalChatterId || chatterName).slice(0, 128),
message: String(message).slice(0, 500),
++ messageId: String(messageId || crypto.randomUUID()).slice(0, 128),
...(absAvatar ? { avatarUrl: absAvatar } : {}),
...(isModerator ? { isModerator: true } : {}),
++ ...(isSubscriber ? { isSubscriber: true } : {}),
},
});
_relayOk(streamerUserId);
} catch (e) {
++ _noteApiError(streamerUserId, 'chat:write', e);
// This used to be silently swallowed, which meant a broken overlay relay looked
// exactly like a working one. Log the first failure and then at most one per
// minute per streamer, so a persistent problem is visible without flooding a
@@@ -117,12 +158,38 @@ async function forwardFollow(streamerUserId, { followerName, externalId } = {})
try {
await oauth.apiRequest(streamerUserId, {
method: 'POST', path: '/follows',
++ // externalId is REQUIRED (idempotency) — a follow/unfollow/follow cycle
++ // reuses the same id, so PowerChat dedupes instead of re-alerting.
body: {
followerName: String(followerName).slice(0, 48),
-- ...(externalId ? { externalId: String(externalId).slice(0, 128) } : {}),
++ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),
++ occurredAt: new Date().toISOString(),
},
});
-- } catch { /* silent */ }
++ } catch (e) { _noteApiError(streamerUserId, 'follows:write', e); }
++}
++
++// ── subscriptions:write ──────────────────────────────────────
++// A paid OpenVibe channel subscription → PowerChat sub alert + goal/subathon credit.
++async function forwardSubscription(streamerUserId, { subscriberName, externalId, tier, isResub, isGift, giftCount } = {}) {
++ if (!subscriberName) return;
++ const conn = _connFor(streamerUserId, 'subscriptions:write');
++ if (!conn) return;
++ try {
++ await oauth.apiRequest(streamerUserId, {
++ method: 'POST', path: '/subscriptions',
++ // Schema: subscriberName 1-48, externalId 1-128 (REQUIRED), tier ≤32,
++ // isResub/isGift bool, giftCount 1-1000.
++ body: {
++ subscriberName: String(subscriberName).slice(0, 48),
++ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),
++ ...(tier ? { tier: String(tier).slice(0, 32) } : {}),
++ ...(isResub ? { isResub: true } : {}),
++ ...(isGift ? { isGift: true, ...(giftCount >= 1 ? { giftCount: Math.min(1000, Math.round(giftCount)) } : {}) } : {}),
++ occurredAt: new Date().toISOString(),
++ },
++ });
++ } catch (e) { _noteApiError(streamerUserId, 'subscriptions:write', e); }
}
// ── viewcount:write ──────────────────────────────────────────
@@@ -134,6 +201,8 @@ const _lastViewCount = new Map(); // userId → { count, sentAt }
async function sendViewCount(streamerUserId, count) {
const conn = _connFor(streamerUserId, 'viewcount:write');
if (!conn) return;
++ // Schema: count is int ≥0, or null = stream ended (clears our chip).
++ count = count == null ? null : Math.max(0, Math.round(Number(count) || 0));
const prev = _lastViewCount.get(streamerUserId);
const now = Date.now();
// Push on change, or when the last push is going stale (heartbeat).
@@@ -141,7 +210,10 @@ async function sendViewCount(streamerUserId, count) {
_lastViewCount.set(streamerUserId, { count, sentAt: now });
try {
await oauth.apiRequest(streamerUserId, { method: 'POST', path: '/view-count', body: { count } });
-- } catch { _lastViewCount.delete(streamerUserId); /* let the next tick retry */ }
++ } catch (e) {
++ _lastViewCount.delete(streamerUserId); // let the next tick retry
++ _noteApiError(streamerUserId, 'viewcount:write', e);
++ }
}
// Periodic sweeper: push each connected live streamer's viewer count; push null once
@@@ -193,10 +265,49 @@ async function sendCurrencyRedemption(streamerUserId, { amount, redeemerName, re
redeemerName: String(redeemerName || 'viewer').slice(0, 48),
...(rewardName ? { rewardName: String(rewardName).slice(0, 64) } : {}),
...(message ? { message: String(message).slice(0, 250) } : {}),
-- ...(externalId ? { externalId: String(externalId).slice(0, 128) } : {}),
++ // externalId is REQUIRED (idempotency) — always send one.
++ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),
++ occurredAt: new Date().toISOString(),
},
});
-- } catch { /* silent */ }
++ } catch (e) {
++ _noteApiError(streamerUserId, 'currency:write', e);
++ // "Unknown currency" means the key isn't DECLARED on the app in the PowerChat
++ // dashboard — that's app config, not auth; surface it distinctly.
++ if (e && e.status === 400 && /unknown currency/i.test(e.message || '')) {
++ console.warn(`[PowerChat] currency "${CURRENCY_KEY}" is not declared on the app — declare it in the PowerChat Developer dashboard`);
++ }
++ }
++}
++
++// ── tips:write ───────────────────────────────────────────────
++// Forward an on-site Vibes donation as a MONETARY tip. amount is in Vibes UNITS;
++// PowerChat converts server-side via the declared unitsPerUsd (100 Vibes = $1) and the
++// tip then fires PowerChat tip alerts and credits tip goals/subathon/totals.
++// externalId MUST be the donation's stable id (retries dedupe; never double-alert).
++async function forwardTip(streamerUserId, { amount, tipperName, message, externalId } = {}) {
++ const conn = _connFor(streamerUserId, 'tips:write');
++ if (!conn) return;
++ const amt = Math.round(Number(amount) || 0);
++ if (amt < 1) return; // schema: int ≥1
++ try {
++ await oauth.apiRequest(streamerUserId, {
++ method: 'POST', path: '/tips',
++ body: {
++ currency: TIP_CURRENCY_KEY,
++ amount: amt,
++ tipperName: String(tipperName || 'Someone').slice(0, 48),
++ ...(message ? { message: String(message).slice(0, 250) } : {}),
++ externalId: String(externalId || crypto.randomUUID()).slice(0, 128),
++ occurredAt: new Date().toISOString(),
++ },
++ });
++ } catch (e) {
++ _noteApiError(streamerUserId, 'tips:write', e);
++ if (e && e.status === 400 && /unknown currency|no usd rate/i.test(e.message || '')) {
++ console.warn(`[PowerChat] tip currency "${TIP_CURRENCY_KEY}" must be declared on the app WITH unitsPerUsd=100 in the PowerChat Developer dashboard`);
++ }
++ }
}
// Fire a display-only custom alert on PowerChat (used by "Send test tip" so the
@@@ -219,7 +330,8 @@ async function sendCustomAlert(streamerUserId, { actorName, message, amountCents
}
module.exports = {
-- CURRENCY_KEY,
-- forwardChat, forwardFollow, sendViewCount, startViewerCountSweeper,
++ CURRENCY_KEY, TIP_CURRENCY_KEY,
++ forwardChat, forwardFollow, forwardSubscription, forwardTip,
++ sendViewCount, startViewerCountSweeper,
sendCurrencyRedemption, sendCustomAlert,
};
ddiff --git a/server/integrations/powerchat-routes.js b/server/integrations/powerchat-routes.js
iindex d4b0b039..180115b2 100644
---- a/server/integrations/powerchat-routes.js
++++ b/server/integrations/powerchat-routes.js
@@@ -12,6 +12,7 @@
'use strict';
const express = require('express');
++const crypto = require('crypto');
const router = express.Router();
const db = require('../db/database');
const config = require('../config');
@@@ -65,6 +66,11 @@ router.get('/status', requireAuth, (req, res) => {
// sandbox self-connect (no tokens) so the card can say which it is.
const connection_kind = connected ? (conn.refresh_token ? 'app' : 'testing') : null;
const scopes = conn && conn.scope ? String(conn.scope).split(/\s+/).filter(Boolean) : [];
++ // Scopes the app now requests that this grant was minted WITHOUT (added since
++ // the streamer connected). Grants never gain scopes retroactively — the fix is
++ // a reconnect (re-consent), so the UI can prompt for exactly that.
++ const wanted = String(cfg.scopes).split(/\s+/).filter(Boolean);
++ const missing_scopes = (connected && conn.scope) ? wanted.filter(sc => !scopes.includes(sc)) : [];
res.json({
enabled: cfg.enabled,
configured: oauth.isConfigured(),
@@@ -74,6 +80,7 @@ router.get('/status', requireAuth, (req, res) => {
tip_page_url: conn ? conn.tip_page_url : null,
scope: conn ? conn.scope : null,
scopes,
++ missing_scopes,
last_error: conn ? conn.last_error : null,
sandbox_username: cfg.sandboxUsername,
});
@@@ -209,23 +216,73 @@ router.post('/test-tip', requireAuth, async (req, res) => {
}
});
--// ── POST /test-alert — fire PowerChat's own overlay test-alert (needs alerts:trigger) ─
--// /test-alerts requires a `kind` discriminator (tip|subscribe|follow|host|emote-wall|
--// view-count|channel_points) + kind-specific fields; an empty body 400s.
++// ── POST /test-alert — fire a fake event of any kind on the PowerChat overlay ─────────
++// body: { kind } — so the streamer can verify each part of the integration end to end.
++//
++// Two transports, chosen per kind:
++// - tip/subscribe/follow/channel_points/host → PowerChat's /test-alerts endpoint
++// (alerts:trigger). Purpose-built for this: display-only, attributed to the app,
++// never credits goals/subathon/leaderboards. Body is a discriminated union — `kind`
++// at the top level, kind-specific fields nested under `payload` (strict validation
++// rejects loose top-level fields). App calls may not use the view-count/emote-wall
++// kinds (rejected), which is why those go through the real intake below.
++// - chat → the REAL POST /chat intake (chat:write): a test line through the actual
++// moderation pipeline into the unified chat overlay — harmless, and the truest test.
++// - view-count → the REAL POST /view-count intake (viewcount:write): the branded chip
++// appears with the streamer's live count (or 42 when offline); PowerChat's ~90s
++// freshness sweep clears a test push on its own.
++const TEST_KINDS = {
++ tip: { scope: 'alerts:trigger', note: 'A tip alert should appear on your PowerChat overlay now.' },
++ subscribe: { scope: 'alerts:trigger', note: 'A subscription alert should appear on your PowerChat overlay now.' },
++ follow: { scope: 'alerts:trigger', note: 'A follow alert should appear on your PowerChat overlay now.' },
++ channel_points: { scope: 'alerts:trigger', note: 'A channel-points redeem alert should appear on your PowerChat overlay now.' },
++ host: { scope: 'alerts:trigger', note: 'A host alert should appear on your PowerChat overlay now.' },
++ chat: { scope: 'chat:write', note: 'A test message should appear in your PowerChat unified chat overlay now.' },
++ 'view-count': { scope: 'viewcount:write', note: 'The OpenVibe.Live viewer chip should appear on PowerChat now (a test push clears itself within ~90s).' },
++};
router.post('/test-alert', requireAuth, async (req, res) => {
++ const kind = String((req.body && req.body.kind) || 'tip');
++ const spec = TEST_KINDS[kind];
++ if (!spec) return res.status(400).json({ error: `Unknown test kind "${kind}"` });
++ const actorName = String(req.user.display_name || req.user.username || 'Test').slice(0, 48);
try {
-- const actorName = req.user.display_name || req.user.username || 'Test';
-- await oauth.apiRequest(req.user.id, {
-- method: 'POST', path: '/test-alerts',
-- body: { kind: 'tip', actorName, amountCents: 500, message: 'Test overlay alert from OpenVibe.Live ✨' },
-- });
-- res.json({ ok: true });
++ if (kind === 'chat') {
++ await oauth.apiRequest(req.user.id, {
++ method: 'POST', path: '/chat',
++ body: {
++ chatterName: actorName,
++ externalChatterId: `test-u${req.user.id}`,
++ message: 'Test message from OpenVibe.Live — your chat relay works ✨',
++ messageId: `test-${crypto.randomUUID()}`,
++ },
++ });
++ } else if (kind === 'view-count') {
++ // Push the real live count when there is one, so the chip shows the truth;
++ // 42 is the recognizable stand-in when testing while offline.
++ let live = [];
++ try { live = db.getLiveStreamsByUserId(req.user.id) || []; } catch { /* */ }
++ const count = live.length ? live.reduce((a, s) => a + (s.viewer_count || 0), 0) : 42;
++ await oauth.apiRequest(req.user.id, { method: 'POST', path: '/view-count', body: { count } });
++ } else {
++ const payloads = {
++ tip: { amountCents: 500, currency: 'usd', tipperName: actorName, message: 'Test tip alert from OpenVibe.Live ✨' },
++ subscribe: { subscriberName: actorName, tier: '1' },
++ follow: { followerName: actorName },
++ channel_points: { redeemerName: actorName, rewardName: 'Test Reward', rewardCost: 500, userInput: 'Test redeem from OpenVibe.Live ✨' },
++ host: { fromChannel: actorName, viewers: 42 },
++ };
++ await oauth.apiRequest(req.user.id, {
++ method: 'POST', path: '/test-alerts',
++ body: { kind, payload: payloads[kind] },
++ });
++ }
++ res.json({ ok: true, kind, note: spec.note });
} catch (err) {
// Missing scope → tell the user to reconnect to grant it (friendly, actionable).
-- if (err.status === 403 && /alerts:trigger/i.test(err.message || '')) {
-- return res.status(403).json({ error: 'reconnect_required', message: 'Reconnect your PowerChat account to enable overlay test alerts (the "trigger alerts" permission was added).' });
++ if (err.status === 403) {
++ return res.status(403).json({ error: 'reconnect_required', message: `Reconnect your PowerChat account to enable this test (it needs the "${spec.scope}" permission).` });
}
-- res.status(err.status === 403 ? 403 : 502).json({ error: err.message });
++ res.status(502).json({ error: err.message });
}
});
ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex cbc474fc..8f21bb68 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -171,6 +171,17 @@ function processEvent(envelope) {
const userId = _resolveStreamerUserId(envelope.streamer);
if (!userId) { console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer); return; }
const data = envelope.data || {};
++ // Dashboard "Send test webhook" deliveries carry data.isTest — they exist to verify
++ // the receiver (signature/dedupe/wiring), and must NEVER credit goals or fire the
++ // real celebration pipeline.
++ if (data.isTest) { console.log(`[PowerChat] test webhook ${envelope.type} verified OK (not credited)`); return; }
++ // App-sourced events are OUR OWN pushes (tips/subs/follows/redemptions we forwarded
++ // via the platform scopes) echoing back through the webhook — they already happened
++ // locally, so replaying them would double-count/duplicate every one.
++ if (data.source === 'developer_app') {
++ console.log(`[PowerChat] skipping app-sourced ${envelope.type} echo (${data.eventId || 'no id'})`);
++ return;
++ }
try {
switch (envelope.type) {
case 'donation.completed':
ddiff --git a/server/monetization/payments.js b/server/monetization/payments.js
iindex bde8b81d..02717f87 100644
---- a/server/monetization/payments.js
++++ b/server/monetization/payments.js
@@@ -249,6 +249,10 @@ function fulfillBucksOrder(order) {
function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null } = {}) {
if (!order || !order.streamer_id) return null;
const end = periodEnd || new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();
++ // Note BEFORE the upsert whether this subscriber already had a sub row — that makes
++ // a renewal a resub for the PowerChat alert below.
++ let isResub = false;
++ try { isResub = !!db.getActiveSubscription(order.user_id, order.streamer_id); } catch { /* */ }
const sub = db.upsertSubscription({
subscriber_id: order.user_id, streamer_id: order.streamer_id, tier: 1,
provider: order.provider, provider_ref: providerRef || order.provider_ref,
@@@ -262,6 +266,18 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null
const streamerBucks = bucksForUsd((order.amount_cents / 100) * (sharePct / 100));
if (streamerBucks > 0) db.addVibesCashout(order.streamer_id, streamerBucks);
db.updatePaymentOrder(order.id, { status: 'credited' });
++ // Fire the sub on the streamer's PowerChat overlay (subscriptions:write) —
++ // alerts + sub-goal/subathon credit. Keyed by the order id so a replayed
++ // fulfillment can't double-alert. Only on first credit, never on re-runs.
++ try {
++ const subscriber = db.getUserById(order.user_id);
++ require('../integrations/powerchat-platform').forwardSubscription(order.streamer_id, {
++ subscriberName: subscriber?.display_name || subscriber?.username || 'Someone',
++ externalId: `sub-order:${order.id}`,
++ tier: '1',
++ isResub,
++ });
++ } catch { /* non-critical */ }
}
return sub;
}
ddiff --git a/server/monetization/routes.js b/server/monetization/routes.js
iindex 21d6792e..390a728f 100644
---- a/server/monetization/routes.js
++++ b/server/monetization/routes.js
@@@ -101,6 +101,19 @@ router.post('/donate', requireAuth, (req, res) => {
// 2) Donation sound (streamer-configured).
alerts.playAlertSound(chatServer, streamer_id, stream_id, 'donation');
++ // Mirror the donation onto the streamer's PowerChat overlay as a monetary tip
++ // (tips:write). Vibes ARE the declared currency units (100 = $1); PowerChat
++ // converts server-side. externalId is the ledger id, so a retry can't double-alert.
++ // The webhook echo of this event (source=developer_app) is deliberately ignored.
++ try {
++ require('../integrations/powerchat-platform').forwardTip(streamer_id, {
++ amount: result.amount,
++ tipperName: donor,
++ message: message || '',
++ externalId: result.transactionId ? `donation:${result.transactionId}` : undefined,
++ });
++ } catch { /* non-critical */ }
++
// 3) Live goal progress → widget.
if (result.goal) {
chatServer.broadcastToChannelRoom(streamer_id, stream_id || null, { type: 'goal-update', goal: publicGoal(result.goal) });
ddiff --git a/server/monetization/vibes.js b/server/monetization/vibes.js
iindex 7e370180..3aa3c994 100644
---- a/server/monetization/vibes.js
++++ b/server/monetization/vibes.js
@@@ -117,7 +117,7 @@ class Vibes {
db.addVibesCashout(toUserId, amount);
// Record transaction
-- db.createTransaction({
++ const txn = db.createTransaction({
from_user_id: fromUserId,
to_user_id: toUserId,
stream_id: streamId,
@@@ -133,6 +133,7 @@ class Vibes {
return {
success: true,
amount,
++ transactionId: txn && txn.lastInsertRowid ? Number(txn.lastInsertRowid) : null,
goal: goalResult ? goalResult.goal : null, // the goal that advanced (for the widget)
goalReached: goalResult && goalResult.reached ? goalResult.goal : null,
};