openvibers/openvibe.live Commits

PowerChat: verify relayed chat was actually displayed (the 202 trap)

OP OpenVibers committed 27 Aug 2026 · 91dbb7505b · 8 files, +243 −9

POST /chat answers 202 { accepted: true } — accepted for moderation, not
displayed. A relay could be dropped by a moderation block, AI/profanity
drop_message, or duplicate-messageId suppression with no callback, and we
treated the 202 as proof of delivery.

- forwardChat now returns 'accepted' (never 'delivered') and queues the id.
- A verifier reads GET /chat/history back once per streamer per 5s tick,
  only while ids are pending (≤12 reads/min/streamer, one call for every
  id in flight), matching PowerChat's app:<appId>:<id> namespace. Ids absent
  after 45s count as dropped — surfaced on /status (chat_relay + a
  'chat_dropped' diagnosis) and logged once/min, never re-sent (a resend
  under a new id would bypass the streamer's moderation).
- chat:read is now requested (default, seed, and the un-customized-row
  migration); grants without it count relays as 'unverified' quietly rather
  than flashing a reconnect error on every message.

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 a43817ad..1cc6333d 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=9" defer></script>
++<script src="/js/powerchat.js?v=10" 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 d5450fa0..53d77856 100644
---- a/public/js/powerchat.js
++++ b/public/js/powerchat.js
@@@ -86,10 +86,13 @@
</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 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 diag = Array.isArray(st.diagnosis) ? st.diagnosis : [];
++ const okDiag = diag.find(d => d.code === 'scopes_ok');
++ // Everything that isn't "all good" (and isn't the missing-scopes prompt above)
++ // is worth a line: token rejected, identity mismatch, dropped relays, last_error.
++ const notes = diag.filter(d => d.level !== 'ok' && d.code !== 'missing_scopes' && d.code !== 'last_error');
const base = st.last_error ? ('Note: ' + st.last_error)
-- : (meFail ? meFail.message
++ : (notes.length ? notes.map(d => d.message).join(' ')
: (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/db/database.js b/server/db/database.js
iindex f4fddb0e..ee5aff34 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1103,7 +1103,7 @@ function initDb() {
// 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_scopes', 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write chat:read', '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'],
// Site-wide tips account: the PowerChat USERNAME whose tip page hosts all
// Vibes purchases and the donation fallback for streamers without their own
@@@ -1122,10 +1122,11 @@ function initDb() {
// 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 (?, ?)`)
++ 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');
++ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write',
++ 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write');
database.prepare(`UPDATE site_settings SET value = 'alex' WHERE key = 'powerchat_sandbox_username' AND value = 'n8admin'`).run();
// powerchat_site_user_id (pointed at an OpenVibe user's connection) is replaced
// by powerchat_site_tip_username (a directly-typed PowerChat username). Carry
ddiff --git a/server/index.js b/server/index.js
iindex 9ecd98bf..02c31a58 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1016,6 +1016,8 @@ async function start() {
try { require('./integrations/powerchat-platform').startViewerCountSweeper(); } catch (e) { console.warn('[PowerChat] viewer sweeper not started:', e.message); }
// Batched channel-point earns → PowerChat leaderboard feed.
try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }
++ // Read /chat/history back so a 202-accepted relay that moderation dropped is visible.
++ try { require('./integrations/powerchat-platform').startChatVerifier(); } catch (e) { console.warn('[PowerChat] chat verifier not started:', e.message); }
// Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).
try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }
// 5-minute live-viewer samples → the home hero's 24h sparkline.
ddiff --git a/server/integrations/powerchat-oauth.js b/server/integrations/powerchat-oauth.js
iindex 313ea545..87d9d116 100644
---- a/server/integrations/powerchat-oauth.js
++++ b/server/integrations/powerchat-oauth.js
@@@ -41,9 +41,11 @@ function getConfig() {
// 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.
++ // chat:read is diagnostic only: it lets the relay read /chat/history back to
++ // confirm a 202-accepted message was actually displayed.
// 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',
++ 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 chat:read',
sandboxUsername: s('powerchat_sandbox_username') || 'alex',
authorizeUrl: `${baseUrl}/oauth/authorize`,
tokenUrl: `${baseUrl}/oauth/token`,
ddiff --git a/server/integrations/powerchat-platform.js b/server/integrations/powerchat-platform.js
iindex a85ef402..6ac9d8e5 100644
---- a/server/integrations/powerchat-platform.js
++++ b/server/integrations/powerchat-platform.js
@@@ -155,6 +155,7 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
if (b.count >= 100) return;
b.count++;
const absAvatar = _absoluteAvatarUrl(avatarUrl);
++ const sentId = String(messageId || crypto.randomUUID()).slice(0, 128);
try {
await oauth.apiRequest(streamerUserId, {
method: 'POST', path: '/chat',
@@@ -167,7 +168,7 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
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),
++ messageId: sentId,
...(absAvatar ? { avatarUrl: absAvatar } : {}),
// Placeholder grapheme when no avatar URL resolves (e.g. 🤖 for AI
// viewers, or a clean letter for "[RS] name"-style prefixed sources).
@@@ -177,6 +178,9 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
},
});
_relayOk(streamerUserId);
++ // 202 = ACCEPTED FOR MODERATION, not displayed. Queue the id for read-back.
++ _trackAccepted(streamerUserId, sentId);
++ return 'accepted';
} catch (e) {
_noteApiError(streamerUserId, 'chat:write', e);
// This used to be silently swallowed, which meant a broken overlay relay looked
@@@ -184,9 +188,124 @@ async function forwardChat(streamerUserId, { chatterName, externalChatterId, mes
// minute per streamer, so a persistent problem is visible without flooding a
// busy chat with one line per message.
_relayFail(streamerUserId, e && e.message);
++ return false;
}
}
++// ── Display verification: the 202 trap ───────────────────────
++// POST /chat answers 202 { accepted: true } — accepted for MODERATION. The message can
++// still be dropped by a moderation block, AI moderation, a profanity drop_message
++// rule, or duplicate-messageId suppression, with no callback. GET /chat/history
++// (scope chat:read) is the only way to know, so accepted ids are queued and read
++// back in one history call per streamer per tick — never one read per message.
++// Verification is observability, not delivery: a dropped message is counted and
++// surfaced on /status (and logged, rate-limited), never re-sent (a resend with a
++// new id would bypass the streamer's moderation on purpose).
++const VERIFY_DELAY_MS = 2500; // let moderation + the history ring settle
++const VERIFY_TICK_MS = 5000; // ≤12 history reads / min / streamer, only while pending
++const VERIFY_MAX_AGE_MS = 45000; // not in history by then → dropped
++const VERIFY_MAX_PENDING = 200; // per streamer; beyond that, oldest are marked unverified
++const HISTORY_LIMIT = 100;
++let _now = () => Date.now();
++const _verify = new Map(); // userId → { pending: Map(messageId → sentAt), lastCheckAt, stats }
++function _verifyState(userId) {
++ let v = _verify.get(userId);
++ if (!v) { v = { pending: new Map(), lastCheckAt: 0, stats: { accepted: 0, displayed: 0, dropped: 0, unverified: 0, lastDroppedAt: null, lastDroppedId: null, lastCheckAt: null } }; _verify.set(userId, v); }
++ return v;
++}
++function _trackAccepted(userId, messageId) {
++ const v = _verifyState(userId);
++ v.stats.accepted++;
++ if (v.pending.size >= VERIFY_MAX_PENDING) {
++ const oldest = v.pending.keys().next().value;
++ v.pending.delete(oldest); v.stats.unverified++;
++ }
++ v.pending.set(messageId, _now());
++}
++// Scope check WITHOUT noting a gap: chat:read is a diagnostic scope — a grant minted
++// before it was requested must not flash "Reconnect needed" on every relay; /status's
++// missing_scopes diff already prompts the reconnect once.
++function _quietConn(userId, scope) {
++ try {
++ if (!oauth.getConfig().enabled) return null;
++ const conn = db.getPowerchatConnection(userId);
++ if (!conn || !conn.access_token || !conn.refresh_token) return null;
++ if (conn.scope && !String(conn.scope).split(/\s+/).includes(scope)) return null;
++ return conn;
++ } catch { return null; }
++}
++const _dropWarned = new Map();
++function _historyIds(json) {
++ const d = json && json.data !== undefined ? json.data : json;
++ const rows = Array.isArray(d) ? d : (d && (d.rows || d.messages || d.items)) || [];
++ const ids = new Set();
++ for (const r of rows) {
++ if (!r || typeof r !== 'object') continue;
++ for (const k of ['messageId', 'id', 'externalId']) if (r[k] != null) ids.add(String(r[k]));
++ }
++ return ids;
++}
++function _historyHas(ids, messageId) {
++ if (ids.has(messageId)) return true;
++ // PowerChat namespaces app ids server-side: "app:<appId>:<ourId>".
++ const suffix = `:${messageId}`;
++ for (const id of ids) if (id.endsWith(suffix)) return true;
++ return false;
++}
++async function _verifyTick() {
++ const now = _now();
++ for (const [userId, v] of _verify) {
++ if (!v.pending.size) continue;
++ if (now - v.lastCheckAt < VERIFY_TICK_MS) continue;
++ let due = false;
++ for (const sentAt of v.pending.values()) if (now - sentAt >= VERIFY_DELAY_MS) { due = true; break; }
++ if (!due) continue;
++ const conn = _quietConn(userId, 'chat:read');
++ if (!conn) {
++ // Can't verify without chat:read — count as unverified, don't pretend.
++ v.stats.unverified += v.pending.size; v.pending.clear();
++ continue;
++ }
++ v.lastCheckAt = now;
++ let ids;
++ try {
++ const json = await oauth.apiRequest(userId, { method: 'GET', path: '/chat/history', query: { limit: HISTORY_LIMIT } });
++ ids = _historyIds(json);
++ v.stats.lastCheckAt = new Date(now).toISOString();
++ } catch (e) {
++ _noteApiError(userId, 'chat:read', e);
++ continue; // transient — entries stay pending until max age
++ }
++ for (const [id, sentAt] of v.pending) {
++ if (now - sentAt < VERIFY_DELAY_MS) continue;
++ if (_historyHas(ids, id)) { v.pending.delete(id); v.stats.displayed++; continue; }
++ if (now - sentAt >= VERIFY_MAX_AGE_MS) {
++ v.pending.delete(id); v.stats.dropped++;
++ v.stats.lastDroppedAt = new Date(now).toISOString(); v.stats.lastDroppedId = id;
++ if ((_dropWarned.get(userId) || 0) < now - 60000) {
++ _dropWarned.set(userId, now);
++ console.warn(`[PowerChat] chat relay for user ${userId}: message ${id} was accepted (202) but never displayed — dropped by moderation/dedupe on PowerChat (${v.stats.dropped} total)`);
++ }
++ }
++ }
++ }
++}
++let _verifyTimer = null;
++function startChatVerifier() {
++ if (_verifyTimer) return;
++ _verifyTimer = setInterval(() => { _verifyTick().catch(() => { }); }, 1000);
++ if (_verifyTimer.unref) _verifyTimer.unref();
++ console.log('[PowerChat] chat display verifier started (reads /chat/history back for accepted relays)');
++}
++/** Relay health for /status: what was accepted vs actually shown on the overlay. */
++function chatRelayStats(userId) {
++ const v = _verify.get(userId);
++ const st = v ? { ...v.stats } : { accepted: 0, displayed: 0, dropped: 0, unverified: 0, lastDroppedAt: null, lastDroppedId: null, lastCheckAt: null };
++ st.pending = v ? v.pending.size : 0;
++ st.verifiable = !!_quietConn(userId, 'chat:read');
++ return st;
++}
++
// Relay health: quiet on success, but surface failures at a sane rate.
const _relayState = new Map(); // userId → { failAt, okAfterFail }
function _relayOk(userId) {
@@@ -477,4 +596,6 @@ module.exports = {
forwardChat, forwardFollow, forwardSubscription, forwardTip,
sendViewCount, startViewerCountSweeper,
sendCurrencyRedemption, queueCurrencyEarn, startCurrencyEarnFlusher, sendCustomAlert,
++ startChatVerifier, chatRelayStats,
++ _test: { verifyTick: _verifyTick, setNow: (fn) => { _now = fn || (() => Date.now()); }, reset: () => _verify.clear() },
};
ddiff --git a/server/integrations/powerchat-routes.js b/server/integrations/powerchat-routes.js
iindex 871a9678..4fb68bce 100644
---- a/server/integrations/powerchat-routes.js
++++ b/server/integrations/powerchat-routes.js
@@@ -119,6 +119,13 @@ router.get('/status', requireAuth, async (req, res) => {
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 */ }
++ // Chat relay: accepted (202) vs actually displayed, from the /chat/history read-back.
++ let chat_relay = null;
++ try {
++ chat_relay = require('./powerchat-platform').chatRelayStats(req.user.id);
++ if (chat_relay.dropped > 0) diagnosis.push({ level: 'warn', code: 'chat_dropped', message: `${chat_relay.dropped} relayed chat message(s) were accepted by PowerChat but never displayed (moderation block, AI/profanity drop, or duplicate id) — check your PowerChat moderation settings. Last: ${chat_relay.lastDroppedAt || 'n/a'}.` });
++ else if (connected && !chat_relay.verifiable && chat_relay.accepted > 0) diagnosis.push({ level: 'info', code: 'chat_unverified', message: 'Relayed chat is accepted by PowerChat but cannot be verified as displayed until you reconnect with the chat:read permission.' });
++ } catch { /* optional */ }
if (conn && conn.last_error) diagnosis.push({ level: 'warn', code: 'last_error', message: conn.last_error });
res.json({
@@@ -136,6 +143,7 @@ router.get('/status', requireAuth, async (req, res) => {
live: live ? { username: live.username, id: live.id, app_id: live.appId, verified_at: new Date().toISOString() } : null,
live_error,
diagnosis,
++ chat_relay,
last_error: conn ? conn.last_error : null,
sandbox_username: cfg.sandboxUsername,
});
ddiff --git a/test/powerchat-chat-verify.test.js b/test/powerchat-chat-verify.test.js
nnew file mode 100644
iindex 00000000..c24d8901
---- /dev/null
++++ b/test/powerchat-chat-verify.test.js
@@@ -0,0 +1,97 @@
++// The 202 trap: POST /chat "accepted" ≠ displayed. The verifier reads /chat/history
++// back once per tick per streamer and classifies accepted ids as displayed / dropped /
++// unverified (no chat:read). DB, OAuth and config are stubbed.
++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 conns = {
++ 1: { user_id: 1, powerchat_username: 'goosely', access_token: 'a', refresh_token: 'r', scope: 'chat:write chat:read' },
++ 2: { user_id: 2, powerchat_username: 'legacy', access_token: 'a', refresh_token: 'r', scope: 'chat:write' }, // no chat:read
++};
++stub(path.join(__dirname, '../server/db/database'), {
++ getPowerchatConnection: (id) => conns[id] || null,
++ setPowerchatConnectionError: () => {}, getSetting: () => null, getUserById: () => null,
++});
++stub(path.join(__dirname, '../server/config'), { baseUrl: 'https://openvibe.live' });
++const api = { calls: [], history: [] };
++stub(path.join(__dirname, '../server/integrations/powerchat-oauth'), {
++ getConfig: () => ({ enabled: true, baseUrl: 'https://pc.example', apiBase: 'https://pc.example/api/dev/v1' }),
++ apiRequest: async (userId, req) => {
++ api.calls.push({ userId, ...req });
++ if (req.path === '/chat') return { data: { accepted: true } };
++ if (req.path === '/chat/history') return { data: api.history };
++ throw new Error('unexpected ' + req.path);
++ },
++});
++const platform = require('../server/integrations/powerchat-platform');
++
++(async () => {
++ let t = 1_000_000;
++ platform._test.setNow(() => t);
++
++ // Two relays for streamer 1, one for streamer 2 (no chat:read).
++ assert.strictEqual(await platform.forwardChat(1, { chatterName: 'pat', message: 'hello', messageId: 'm1' }), 'accepted', '202 is reported as accepted, not delivered');
++ assert.strictEqual(await platform.forwardChat(1, { chatterName: 'pat', message: 'again', messageId: 'm2' }), 'accepted');
++ assert.strictEqual(await platform.forwardChat(2, { chatterName: 'pat', message: 'hi', messageId: 'x1' }), 'accepted');
++ assert.deepStrictEqual(api.calls.map(c => c.path), ['/chat', '/chat', '/chat']);
++ assert.strictEqual(api.calls[0].body.messageId, 'm1');
++ let s = platform.chatRelayStats(1);
++ assert.deepStrictEqual([s.accepted, s.displayed, s.dropped, s.pending, s.verifiable], [2, 0, 0, 2, true]);
++
++ // Too early: nothing is read back yet.
++ await platform._test.verifyTick();
++ assert.strictEqual(api.calls.filter(c => c.path === '/chat/history').length, 0);
++
++ // After the settle delay: ONE history read for streamer 1 (both ids in one call);
++ // streamer 2 has no chat:read → its ids become "unverified" without any API call.
++ t += 3000;
++ api.history = [{ messageId: 'app:app_1:m1', platform: 'developer_app', text: 'hello' }, { messageId: 'kick-123', platform: 'kick' }];
++ await platform._test.verifyTick();
++ const reads = api.calls.filter(c => c.path === '/chat/history');
++ assert.strictEqual(reads.length, 1);
++ assert.strictEqual(reads[0].userId, 1);
++ assert.deepStrictEqual(reads[0].query, { limit: 100 });
++ s = platform.chatRelayStats(1);
++ assert.deepStrictEqual([s.displayed, s.dropped, s.pending], [1, 0, 1], 'm1 matched via the app:<id>: namespace; m2 still pending');
++ const s2 = platform.chatRelayStats(2);
++ assert.deepStrictEqual([s2.accepted, s2.unverified, s2.pending, s2.verifiable], [1, 1, 0, false]);
++
++ // Within the tick interval: no second read even though m2 is pending.
++ t += 1000;
++ await platform._test.verifyTick();
++ assert.strictEqual(api.calls.filter(c => c.path === '/chat/history').length, 1);
++
++ // Past max age and still absent from history → dropped (counted, never re-sent).
++ t += 50000;
++ await platform._test.verifyTick();
++ assert.strictEqual(api.calls.filter(c => c.path === '/chat/history').length, 2);
++ assert.strictEqual(api.calls.filter(c => c.path === '/chat').length, 3, 'a dropped message is not re-posted');
++ s = platform.chatRelayStats(1);
++ assert.deepStrictEqual([s.displayed, s.dropped, s.pending, s.lastDroppedId], [1, 1, 0, 'm2']);
++ assert.ok(s.lastDroppedAt);
++
++ // Envelope tolerance: { data: { rows } } and bare arrays / plain ids both match.
++ platform._test.reset();
++ await platform.forwardChat(1, { chatterName: 'p', message: 'a', messageId: 'z1' });
++ await platform.forwardChat(1, { chatterName: 'p', message: 'b', messageId: 'z2' });
++ t += 60000;
++ api.history = { rows: [{ id: 'z1' }, { messageId: 'z2' }] };
++ await platform._test.verifyTick();
++ s = platform.chatRelayStats(1);
++ assert.deepStrictEqual([s.displayed, s.dropped, s.pending], [2, 0, 0]);
++
++ // A transient read failure keeps ids pending (no false "dropped").
++ platform._test.reset();
++ await platform.forwardChat(1, { chatterName: 'p', message: 'c', messageId: 'q1' });
++ t += 6000;
++ const realApi = require.cache[require.resolve('../server/integrations/powerchat-oauth')].exports;
++ const orig = realApi.apiRequest;
++ realApi.apiRequest = async (u, r) => { if (r.path === '/chat/history') throw Object.assign(new Error('boom'), { status: 503 }); return orig(u, r); };
++ await platform._test.verifyTick();
++ s = platform.chatRelayStats(1);
++ assert.deepStrictEqual([s.displayed, s.dropped, s.pending], [0, 0, 1]);
++ realApi.apiRequest = orig;
++
++ console.log('✅ powerchat chat display-verification tests passed');
++})().catch((e) => { console.error('❌', e); process.exit(1); });