openvibers/openvibe.live Commits

PowerChat: GET /me self-diagnosis — diff the live grant against needed scopes

OP OpenVibers committed 27 Aug 2026 · 80d070237d · 5 files, +187 −11

The most common integration failure is a scope that is registered on the app
but was never requested in /oauth/authorize (or switched off later by the
streamer): every intake call 403s while the dashboard says the app has it.
/me needs no scope and returns the grant as PowerChat sees it.

- oauth: fetchMe() (root /me, envelope-tolerant) + pure scopeDiff().
- /status?live=1 calls /me, re-syncs the stored scope / user id / tip page
  from the live grant, and returns a diagnosis[] (missing scopes with the
  reconnect remedy, identity mismatch, token rejected, intents unsupported,
  last_error) next to missing_scopes / extra_scopes / wanted_scopes.
- Dashboard card requests live=1 and shows 'Verified live with PowerChat'
  or the failure reason; the existing reconnect prompt now fires off the
  live diff rather than the connect-time scope string.

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

Changes

ddiff --git a/public/index.html b/public/index.html
iindex f32050b2..a43817ad 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -5093,7 +5093,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
--<script src="/js/powerchat.js?v=8" defer></script>
++<script src="/js/powerchat.js?v=9" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
ddiff --git a/public/js/powerchat.js b/public/js/powerchat.js
iindex 62918926..d5450fa0 100644
---- a/public/js/powerchat.js
++++ b/public/js/powerchat.js
@@@ -20,7 +20,8 @@
if (!card) return;
if (_pcActive) return; // don't clobber the live connecting animation
let st;
-- try { st = await api('/powerchat/status'); }
++ // live=1: also ask PowerChat (GET /me) which scopes the grant REALLY carries.
++ try { st = await api('/powerchat/status?live=1'); }
catch { card.style.display = 'none'; return; }
if (!st || !st.enabled) { card.style.display = 'none'; return; }
@@@ -85,7 +86,11 @@
</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) {
-- const base = st.last_error ? ('Note: ' + st.last_error) : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.';
++ const okDiag = Array.isArray(st.diagnosis) ? st.diagnosis.find(d => d.code === 'scopes_ok') : null;
++ const meFail = Array.isArray(st.diagnosis) ? st.diagnosis.find(d => d.code === 'me_failed' || d.code === 'identity_mismatch') : null;
++ const base = st.last_error ? ('Note: ' + st.last_error)
++ : (meFail ? meFail.message
++ : (okDiag ? okDiag.message : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.'));
// Relay routing lives on the Broadcast page — streamers rarely find it unprompted.
hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a> → a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>→ PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;
}
ddiff --git a/server/integrations/powerchat-oauth.js b/server/integrations/powerchat-oauth.js
iindex 10c8b1bb..313ea545 100644
---- a/server/integrations/powerchat-oauth.js
++++ b/server/integrations/powerchat-oauth.js
@@@ -319,9 +319,45 @@ async function fetchProfile(userId, username) {
return apiRequest(userId, { method: 'GET', path: '/profile', username });
}
++// ── Self-diagnosis: GET /me ──────────────────────────────────────────────────
++// Needs no scope. Returns the streamer the token belongs to and the scopes ACTUALLY
++// granted — the grant is the truth; the dashboard's registered-scope list is only the
++// ceiling. The #1 integration failure ("registered a scope but never REQUESTED it in
++// /oauth/authorize", or a streamer switching a capability off later) shows up only
++// here — every intake call just 403s.
++async function fetchMe(userId) {
++ const json = await apiRequest(userId, { method: 'GET', path: '/me', root: true });
++ const d = (json && json.data && typeof json.data === 'object') ? json.data : json;
++ if (!d || typeof d !== 'object') return null;
++ return {
++ id: d.id != null ? String(d.id) : null,
++ username: d.username || null,
++ displayName: d.displayName || null,
++ tipPageUrl: d.tipPageUrl || null,
++ appId: d.appId || null,
++ scopes: _scopeList(d.scopes),
++ };
++}
++// Normalize a scope set from a space/comma-delimited string or an array.
++function _scopeList(v) {
++ if (Array.isArray(v)) return [...new Set(v.map((x) => String(x).trim()).filter(Boolean))];
++ return [...new Set(String(v || '').split(/[\s,]+/).map((x) => x.trim()).filter(Boolean))];
++}
++// Pure diff of what a grant carries vs what the app needs (both accept string|array):
++// missing → registered/wanted but NOT on the grant (fix: widen scope= and re-consent)
++// extra → on the grant but no longer wanted (harmless; informational)
++function scopeDiff(granted, wanted) {
++ const g = _scopeList(granted), w = _scopeList(wanted);
++ return {
++ granted: g, wanted: w,
++ missing: w.filter((x) => !g.includes(x)),
++ extra: g.filter((x) => !w.includes(x)),
++ };
++}
++
module.exports = {
getConfig, isConfigured, redirectUri,
buildAuthorize, verifyState, exchangeCode, refreshToken, revokeToken,
-- getValidAccessToken, apiRequest, fetchProfile, normalizeToken,
++ getValidAccessToken, apiRequest, fetchProfile, fetchMe, scopeDiff, normalizeToken,
decodeJwtPayload, identityFromToken,
};
ddiff --git a/server/integrations/powerchat-routes.js b/server/integrations/powerchat-routes.js
iindex 9af9ad47..871a9678 100644
---- a/server/integrations/powerchat-routes.js
++++ b/server/integrations/powerchat-routes.js
@@@ -57,20 +57,70 @@ p{color:#aaa;margin:0;max-width:340px;line-height:1.5}
}
// ── GET /status ──────────────────────────────────────────────────────────────
--router.get('/status', requireAuth, (req, res) => {
++router.get('/status', requireAuth, async (req, res) => {
try {
const cfg = oauth.getConfig();
-- const conn = db.getPowerchatConnection(req.user.id);
++ let conn = db.getPowerchatConnection(req.user.id);
const connected = !!(conn && conn.access_token);
// Distinguish a real OAuth app connection (mints refresh tokens) from a
// 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)) : [];
++ const diagnosis = [];
++
++ // ?live=1 → ask PowerChat itself (GET /me, no scope needed) which streamer this
++ // token belongs to and which scopes the grant REALLY carries. The stored scope
++ // column is what the token response said at connect time; the live grant is the
++ // truth (streamers can switch capabilities off later, and a scope registered
++ // in the dashboard but never requested never lands on the grant at all).
++ let live = null, live_error = null;
++ if (connected && conn.refresh_token && String(req.query.live || '') === '1') {
++ try {
++ live = await oauth.fetchMe(req.user.id);
++ if (live) {
++ const patch = {};
++ const liveScope = live.scopes.join(' ');
++ if (liveScope && liveScope !== String(conn.scope || '')) patch.scope = liveScope;
++ if (live.id && String(live.id) !== String(conn.powerchat_user_id || '')) patch.powerchat_user_id = live.id;
++ if (live.tipPageUrl && live.tipPageUrl !== conn.tip_page_url) patch.tip_page_url = live.tipPageUrl;
++ if (live.username && conn.powerchat_username && live.username.toLowerCase() !== String(conn.powerchat_username).toLowerCase()) {
++ diagnosis.push({ level: 'warn', code: 'identity_mismatch', message: `This token belongs to PowerChat user "${live.username}", but the connection was saved as "${conn.powerchat_username}". Reconnect to fix.` });
++ }
++ if (Object.keys(patch).length) conn = db.upsertPowerchatConnection(req.user.id, patch) || conn;
++ }
++ } catch (e) {
++ live_error = { status: e.status || null, message: e.message };
++ diagnosis.push({
++ level: e.status === 401 ? 'error' : 'warn', code: 'me_failed',
++ message: e.status === 401
++ ? 'PowerChat no longer accepts this connection\'s token — reconnect.'
++ : `Couldn't verify the grant with PowerChat right now (${e.status || e.message}).`,
++ });
++ }
++ }
++
++ const scopes = live ? live.scopes : (conn && conn.scope ? String(conn.scope).split(/\s+/).filter(Boolean) : []);
++ // Scopes the app now requests that this grant was minted WITHOUT. Grants never
++ // gain scopes retroactively — the fix is a reconnect (re-consent).
++ const diff = oauth.scopeDiff(scopes, wanted);
++ const missing_scopes = (connected && scopes.length) ? diff.missing : [];
++ if (missing_scopes.length) {
++ diagnosis.push({
++ level: 'warn', code: 'missing_scopes',
++ message: `${live ? 'PowerChat reports' : 'This connection was saved with'} a grant missing: ${missing_scopes.join(', ')}. ` +
++ 'Those features fail with 403 until you reconnect (one click, same account) so the grant is re-minted with them.',
++ scopes: missing_scopes,
++ });
++ } else if (live) {
++ diagnosis.push({ level: 'ok', code: 'scopes_ok', message: `Verified live with PowerChat: all ${wanted.length} permissions are granted to @${live.username}.` });
++ }
++ // Fixed-price checkouts: are intents available on this PowerChat deployment?
++ try {
++ const intents = require('./powerchat-checkout').checkoutIntentSupport();
++ if (intents.state === 'unsupported') diagnosis.push({ level: 'info', code: 'intents_unsupported', message: 'Fixed-price checkout intents are not deployed on this PowerChat yet — subscriptions and Vibes packs use canonical pinned links (amount still enforced by our webhook checks).' });
++ } catch { /* optional */ }
++ if (conn && conn.last_error) diagnosis.push({ level: 'warn', code: 'last_error', message: conn.last_error });
++
res.json({
enabled: cfg.enabled,
configured: oauth.isConfigured(),
@@@ -80,7 +130,12 @@ router.get('/status', requireAuth, (req, res) => {
tip_page_url: conn ? conn.tip_page_url : null,
scope: conn ? conn.scope : null,
scopes,
++ wanted_scopes: wanted,
missing_scopes,
++ extra_scopes: connected ? diff.extra : [],
++ live: live ? { username: live.username, id: live.id, app_id: live.appId, verified_at: new Date().toISOString() } : null,
++ live_error,
++ diagnosis,
last_error: conn ? conn.last_error : null,
sandbox_username: cfg.sandboxUsername,
});
ddiff --git a/test/powerchat-scope-diff.test.js b/test/powerchat-scope-diff.test.js
nnew file mode 100644
iindex 00000000..dc9ce5ae
---- /dev/null
++++ b/test/powerchat-scope-diff.test.js
@@@ -0,0 +1,80 @@
++// Scope-diff diagnostic (GET /me): pure diff + fetchMe envelope handling + the
++// apiRequest 429 path end to end with a mocked fetch. No DB, no network.
++const assert = require('assert');
++const path = require('path');
++
++function stub(modPath, exportsObj) {
++ const full = require.resolve(modPath);
++ require.cache[full] = { id: full, filename: full, loaded: true, exports: exportsObj };
++}
++const settings = { powerchat_enabled: 'true', powerchat_base_url: 'https://pc.example', powerchat_client_id: 'pca_x', powerchat_client_secret: 'sec',
++ powerchat_scopes: 'profile:read webhooks:events checkout:attribute chat:write chat:read' };
++const conn = { user_id: 5, powerchat_username: 'goosely', access_token: 'tok', refresh_token: 'ref', token_expires_at: Date.now() + 3600e3, scope: 'profile:read webhooks:events' };
++stub(path.join(__dirname, '../server/db/database'), {
++ getSetting: (k) => settings[k] ?? null,
++ getPowerchatConnection: (id) => (id === 5 ? conn : null),
++ updatePowerchatTokens: () => {}, setPowerchatConnectionError: () => {},
++});
++stub(path.join(__dirname, '../server/config'), { baseUrl: 'https://openvibe.live' });
++
++const oauth = require('../server/integrations/powerchat-oauth');
++
++(async () => {
++ // ── scopeDiff ──────────────────────────────────────────────────
++ let d = oauth.scopeDiff('profile:read webhooks:events', 'profile:read webhooks:events checkout:attribute chat:write');
++ assert.deepStrictEqual(d.missing, ['checkout:attribute', 'chat:write']);
++ assert.deepStrictEqual(d.extra, []);
++ d = oauth.scopeDiff(['chat:write', 'legacy:thing', 'chat:write'], 'chat:write');
++ assert.deepStrictEqual(d, { granted: ['chat:write', 'legacy:thing'], wanted: ['chat:write'], missing: [], extra: ['legacy:thing'] });
++ d = oauth.scopeDiff(' a:b,c:d ', 'c:d\na:b');
++ assert.deepStrictEqual(d.missing, [], 'comma/whitespace/newline delimiters all normalize');
++ d = oauth.scopeDiff(null, 'x:y');
++ assert.deepStrictEqual(d, { granted: [], wanted: ['x:y'], missing: ['x:y'], extra: [] });
++ d = oauth.scopeDiff('x:y', '');
++ assert.deepStrictEqual(d.missing, []);
++
++ // ── fetchMe: hits /api/dev/v1/me (not /streamers/…), unwraps { data } ──
++ const calls = [];
++ const responses = [];
++ global.fetch = async (url, init) => {
++ calls.push({ url: String(url), auth: init.headers.Authorization });
++ const r = responses.shift() || { status: 200, body: {} };
++ return { ok: r.status < 400, status: r.status, headers: { get: (h) => (r.headers || {})[h.toLowerCase()] ?? null }, json: async () => r.body };
++ };
++ responses.push({ status: 200, body: { data: { id: 42, username: 'goosely', displayName: 'Goosely', tipPageUrl: 'https://pc.example/goosely/tip', appId: 'app_1', scopes: ['profile:read', 'webhooks:events', 'chat:write'] } } });
++ let me = await oauth.fetchMe(5);
++ assert.strictEqual(calls[0].url, 'https://pc.example/api/dev/v1/me');
++ assert.strictEqual(calls[0].auth, 'Bearer tok');
++ assert.deepStrictEqual(me, { id: '42', username: 'goosely', displayName: 'Goosely', tipPageUrl: 'https://pc.example/goosely/tip', appId: 'app_1', scopes: ['profile:read', 'webhooks:events', 'chat:write'] });
++ const diff = oauth.scopeDiff(me.scopes, settings.powerchat_scopes);
++ assert.deepStrictEqual(diff.missing, ['checkout:attribute', 'chat:read'], 'live grant vs wanted list surfaces exactly the un-requested scopes');
++
++ // Older / bare envelope and a space-delimited scope string are tolerated.
++ responses.push({ status: 200, body: { id: 'u1', username: 'x', scopes: 'a:b c:d' } });
++ me = await oauth.fetchMe(5);
++ assert.deepStrictEqual(me.scopes, ['a:b', 'c:d']);
++ assert.strictEqual(me.appId, null);
++
++ // ── apiRequest honours Retry-After on a 429, then succeeds ──
++ calls.length = 0;
++ responses.push({ status: 429, headers: { 'retry-after': '0' }, body: { error: { message: 'Too many requests', code: 'RATE_LIMITED' } } });
++ responses.push({ status: 200, body: { data: { ok: true } } });
++ const out = await oauth.apiRequest(5, { method: 'GET', path: '/profile' });
++ assert.deepStrictEqual(out, { data: { ok: true } });
++ assert.strictEqual(calls.length, 2, 'one retry after the 429');
++ assert.strictEqual(calls[0].url, 'https://pc.example/api/dev/v1/streamers/goosely/profile');
++
++ // A 403 is terminal: one call, error carries status + code.
++ calls.length = 0;
++ responses.push({ status: 403, body: { error: { message: 'missing scope chat:read', code: 'FORBIDDEN' } } });
++ await assert.rejects(oauth.apiRequest(5, { method: 'GET', path: '/chat/history' }), (e) => e.status === 403 && e.code === 'FORBIDDEN' && /chat:read/.test(e.message));
++ assert.strictEqual(calls.length, 1);
++
++ // retry:false → a 429 surfaces immediately (callers that must not block).
++ calls.length = 0;
++ responses.push({ status: 429, headers: { 'retry-after': '0' }, body: {} });
++ await assert.rejects(oauth.apiRequest(5, { method: 'POST', path: '/chat', body: { a: 1 }, retry: false }), (e) => e.status === 429);
++ assert.strictEqual(calls.length, 1);
++
++ console.log('✅ powerchat scope-diff / me tests passed');
++})().catch((e) => { console.error('❌', e); process.exit(1); });