openvibers/openvibe.live Commits
PowerChat: GET /me self-diagnosis — diff the live grant against needed scopes
Changes
@@@ -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>@@@ -20,7 +20,8 @@if (!card) return;if (_pcActive) return; // don't clobber the live connecting animationlet 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>`;}@@@ -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,};@@@ -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,});nnew file mode 100644@@@ -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); });
@@ -5093,7 +5093,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -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/staff-console.js?v=1" defer></script>
<script src="/js/dashboard-moderation.js?v=5" 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/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/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
@@ -20,7 +20,8 @@
@@ -20,7 +20,8 @@
if (!card) return;
if (!card) return;
if (_pcActive) return; // don't clobber the live connecting animation
if (_pcActive) return; // don't clobber the live connecting animation
let st;
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; }
catch { card.style.display = 'none'; return; }
if (!st || !st.enabled) { card.style.display = 'none'; return; }
if (!st || !st.enabled) { card.style.display = 'none'; return; }
@@ -85,7 +86,11 @@
@@ -85,7 +86,11 @@
</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>`;
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) {
} 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.
// 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>`;
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>`;
}
}
@@ -319,9 +319,45 @@ async function fetchProfile(userId, username) {
@@ -319,9 +319,45 @@ async function fetchProfile(userId, username) {
return apiRequest(userId, { method: 'GET', path: '/profile', 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 = {
module.exports = {
getConfig, isConfigured, redirectUri,
getConfig, isConfigured, redirectUri,
buildAuthorize, verifyState, exchangeCode, refreshToken, revokeToken,
buildAuthorize, verifyState, exchangeCode, refreshToken, revokeToken,
- getValidAccessToken, apiRequest, fetchProfile, normalizeToken,
+ getValidAccessToken, apiRequest, fetchProfile, fetchMe, scopeDiff, normalizeToken,
decodeJwtPayload, identityFromToken,
decodeJwtPayload, identityFromToken,
};
};
@@ -57,20 +57,70 @@ p{color:#aaa;margin:0;max-width:340px;line-height:1.5}
@@ -57,20 +57,70 @@ p{color:#aaa;margin:0;max-width:340px;line-height:1.5}
}
}
// ── GET /status ──────────────────────────────────────────────────────────────
// ── GET /status ──────────────────────────────────────────────────────────────
-router.get('/status', requireAuth, (req, res) => {
+router.get('/status', requireAuth, async (req, res) => {
try {
try {
const cfg = oauth.getConfig();
const cfg = oauth.getConfig();
- const conn = db.getPowerchatConnection(req.user.id);
+ let conn = db.getPowerchatConnection(req.user.id);
const connected = !!(conn && conn.access_token);
const connected = !!(conn && conn.access_token);
// Distinguish a real OAuth app connection (mints refresh tokens) from a
// Distinguish a real OAuth app connection (mints refresh tokens) from a
// sandbox self-connect (no tokens) so the card can say which it is.
// sandbox self-connect (no tokens) so the card can say which it is.
const connection_kind = connected ? (conn.refresh_token ? 'app' : 'testing') : null;
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 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({
res.json({
enabled: cfg.enabled,
enabled: cfg.enabled,
configured: oauth.isConfigured(),
configured: oauth.isConfigured(),
@@ -80,7 +130,12 @@ router.get('/status', requireAuth, (req, res) => {
@@ -80,7 +130,12 @@ router.get('/status', requireAuth, (req, res) => {
tip_page_url: conn ? conn.tip_page_url : null,
tip_page_url: conn ? conn.tip_page_url : null,
scope: conn ? conn.scope : null,
scope: conn ? conn.scope : null,
scopes,
scopes,
+ wanted_scopes: wanted,
missing_scopes,
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,
last_error: conn ? conn.last_error : null,
sandbox_username: cfg.sandboxUsername,
sandbox_username: cfg.sandboxUsername,
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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); });