openvibers/openvibe.live Commits

Site tips account: a typed PowerChat username, not a user-id pointer

OP OpenVibers committed 26 Aug 2026 · d595fbb91e · 3 files, +54 −17

powerchat_site_user_id (borrowing an OpenVibe user's dashboard connection)
is replaced by powerchat_site_tip_username — type the PowerChat account
made for site tips into admin settings and its tip page hosts purchases +
fallback donations. Checkout links are canonical URLs built from the
username + the existing app client id, and confirmation is the signed
webhook, so no tokens are needed for it locally; the webhook processor now
fulfills attributed donations for that account even though it has no
connection row (anything unattributed sent to it is deliberately ignored).
Migration carries the old pointer's resolved username over once. The one
external requirement: that account must have the app connected on
PowerChat's side so webhooks fire for it.

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

Changes

ddiff --git a/server/db/database.js b/server/db/database.js
iindex 120c9e04..b0020a6f 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1057,11 +1057,12 @@ function initDb() {
// 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'],
-- // Site-wide fallback PowerChat account: the OpenVibe user id whose connected
-- // PowerChat receives tips when a streamer has no PowerChat of their own, and
-- // hosts all Vibes purchases. That user connects PowerChat from their normal
-- // dashboard card; this setting just points at them (default: the owner).
-- ['powerchat_site_user_id', '1', 'OpenVibe user id whose PowerChat connection is the site-wide tip/purchase account (fallback when a streamer has no PowerChat)', 'number'],
++ // Site-wide tips account: the PowerChat USERNAME whose tip page hosts all
++ // Vibes purchases and the donation fallback for streamers without their own
++ // PowerChat. Just a typed username (the checkout link is a canonical URL) —
++ // the app credentials above handle attribution, and that PowerChat account
++ // must have the app connected on PowerChat's side so webhooks fire for it.
++ ['powerchat_site_tip_username', '', 'PowerChat username whose tip page receives site purchases + fallback donations (that account must have the app connected on PowerChat)', '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);
@@@ -1074,6 +1075,21 @@ function initDb() {
'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();
++ // powerchat_site_user_id (pointed at an OpenVibe user's connection) is replaced
++ // by powerchat_site_tip_username (a directly-typed PowerChat username). Carry
++ // the old pointer's resolved PowerChat username over once, then drop it.
++ const oldSite = database.prepare(`SELECT value FROM site_settings WHERE key = 'powerchat_site_user_id'`).get();
++ if (oldSite) {
++ try {
++ const uid = parseInt(oldSite.value, 10);
++ const conn = uid ? database.prepare('SELECT powerchat_username FROM powerchat_connections WHERE user_id = ?').get(uid) : null;
++ if (conn && conn.powerchat_username) {
++ database.prepare(`UPDATE site_settings SET value = ? WHERE key = 'powerchat_site_tip_username' AND (value IS NULL OR value = '')`)
++ .run(conn.powerchat_username);
++ }
++ } catch { /* best-effort carry-over */ }
++ database.prepare(`DELETE FROM site_settings WHERE key = 'powerchat_site_user_id'`).run();
++ }
} catch (e) { console.warn('[DB] Settings seed:', e.message); }
// Internal job/cache state (JSON blobs the AI jobs persist across restarts).
ddiff --git a/server/integrations/powerchat-checkout.js b/server/integrations/powerchat-checkout.js
iindex 7569db3d..84ae04f4 100644
---- a/server/integrations/powerchat-checkout.js
++++ b/server/integrations/powerchat-checkout.js
@@@ -28,12 +28,24 @@ const db = require('../db/database');
const oauth = require('./powerchat-oauth');
// ── Site account resolution ──────────────────────────────────
--function siteAccountUserId() {
-- const v = parseInt(String(db.getSetting('powerchat_site_user_id') || ''), 10);
-- return Number.isFinite(v) && v > 0 ? v : null;
++// The site tips account is just a TYPED PowerChat username (admin setting) — a
++// dedicated account made for receiving site money. Checkout links are canonical
++// URLs built from the username + our client id; confirmation arrives via the
++// signed webhook, so no tokens are needed on our side. The only external
++// requirement: that PowerChat account must have the app connected on PowerChat
++// (Connect card / OAuth consent) so webhooks fire and refs echo back for it.
++function getSiteAccount() {
++ try {
++ const c = oauth.getConfig();
++ if (!c.enabled || !c.clientId) return null;
++ const username = String(db.getSetting('powerchat_site_tip_username') || '').trim();
++ return username ? { username } : null;
++ } catch { return null; }
}
--// A usable checkout host = connected via app OAuth AND granted checkout:attribute
--// (attribution refs only echo back for grants that carry the scope).
++function isAvailable() { return !!getSiteAccount(); }
++
++// A streamer-direct checkout host = connected via app OAuth AND granted
++// checkout:attribute (attribution refs only echo back for grants carrying the scope).
function _checkoutConn(userId) {
if (!userId) return null;
try {
@@@ -44,12 +56,6 @@ function _checkoutConn(userId) {
return conn;
} catch { return null; }
}
--function getSiteAccount() {
-- const userId = siteAccountUserId();
-- const conn = _checkoutConn(userId);
-- return conn ? { userId, username: conn.powerchat_username } : null;
--}
--function isAvailable() { return !!getSiteAccount(); }
// Canonical attribution link (documented stable URL shape — no API round-trip needed).
function tipLinkFor(pcUsername, ref) {
ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex 0c02d94b..6f177008 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -169,8 +169,23 @@ function _handleNotice(userId, message, kind) {
function processEvent(envelope) {
if (!envelope || !envelope.type) return;
const userId = _resolveStreamerUserId(envelope.streamer);
-- if (!userId) { console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer); return; }
const data = envelope.data || {};
++ if (!userId) {
++ // The site tips account is a bare PowerChat username (no OpenVibe connection
++ // row) — its checkout-attributed donations must still be fulfilled. Anything
++ // else arriving for it (e.g. someone tipping that account directly, outside a
++ // checkout link) has no on-site home and is deliberately ignored.
++ const siteName = String(db.getSetting('powerchat_site_tip_username') || '').trim().toLowerCase();
++ const from = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
++ if (siteName && from === siteName) {
++ if (envelope.type === 'donation.completed' && data.appExternalRef && !data.isTest && data.source !== 'developer_app') {
++ try { require('./powerchat-checkout').handleAttributedDonation(null, data); } catch (e) { console.warn('[PowerChat] site-account fulfillment:', e.message); }
++ }
++ return;
++ }
++ console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer);
++ return;
++ }
// 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.