openvibers/openvibe.live Commits

PowerChat: gate checkout intents behind a capability check; surface expiresAt

OP OpenVibers committed 27 Aug 2026 · b2b572a182 · 3 files, +187 −24

tip-checkout-link ships on PowerChat's 103-integrations-system branch and may
404 in production. A 404 now marks intents unsupported for 15 minutes (one
probe, not one per order) and every pinned checkout falls back to the
canonical app_amount_cents link; a 403 (scope shrunk / redirect not
registered) falls back per call. Both log at most once per 10 minutes.
The intent's expiresAt (one hour, single use) is returned to the checkout
routes as expires_at alongside pinned:true so the UI can tell a locked
price from a best-effort one. Envelope-tolerant: { data: { url } } or a
bare { url }.

Test covers success + URL re-homing, item_name 400 retry, 403 and 404
fallbacks (incl. the cache), missing host token, and both link builders.

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

Changes

ddiff --git a/server/integrations/powerchat-checkout.js b/server/integrations/powerchat-checkout.js
iindex 40969846..6a457057 100644
---- a/server/integrations/powerchat-checkout.js
++++ b/server/integrations/powerchat-checkout.js
@@@ -81,20 +81,36 @@ function tipLinkFor(pcUsername, ref, { purpose = null, amountCents = null, retur
// ── Server-minted checkout intents ───────────────────────────
// PowerChat's fixed-price checkout is an INTENT, not a query string: the app calls
// GET /streamers/:username/tip-checkout-link?amount_cents&purpose&ref&redirect_uri
--// (streamer token, scope checkout:attribute) and gets back a single-use, ~1h URL that
--// carries only app_intent=<token>. Amount/purpose/ref live server-side on the intent,
--// so the viewer can neither edit the price nor strip the correlation ref, and the
--// tip page renders the amount locked. That is the mechanism the plain
++// (streamer token, scope checkout:attribute) and gets back { url, expiresAt } — a
++// single-use, one-hour URL carrying only app_intent=<token>. Amount/purpose/ref live
++// server-side on the intent, so the viewer can neither edit the price nor strip the
++// correlation ref; the tip page renders the amount read-only and a mismatched or
++// replayed submit is a 400 with no Event. That is the mechanism the plain
// app_amount_cents query param only approximates — so every PINNED checkout (Vibes
--// packs, subscriptions) is minted here first and the canonical URL is the fallback
--// (PowerChat versions without intents, token trouble, transient API errors).
++// packs, subscriptions) is minted here first and the canonical URL is the fallback.
++//
++// Deployment gate: the endpoint ships on PowerChat's 103-integrations-system branch
++// and may 404 in production. A 404 marks intents unsupported for a while (no
++// re-probe per order); a 403 (scope shrunk / redirect URI not registered) and any
++// other failure fall back per call. Both are logged at most once per 10 minutes.
//
// Minting needs a token for the HOST account. Streamer-direct hosts have one (their
// own connection); the site tips account has one only when its PowerChat username is
// also connected on this site (the owner's dashboard card) — otherwise it stays on
// the canonical link. `item_name` is the viewer-facing label PowerChat is adding to
--// intents ("1,000 Vibes", "goosely — 1 month sub"); if the running PowerChat rejects
--// the unknown param we retry without it so the fixed amount still lands.
++// intents ("1,000 Vibes", "goosely — 1 month sub"); today `purpose` is a machine key
++// only, and if the running PowerChat rejects the unknown param we retry without it so
++// the fixed amount still lands.
++const INTENT_UNSUPPORTED_MS = 15 * 60 * 1000;
++const _intentSupport = new Map(); // apiBase → { unsupportedUntil, reason }
++function checkoutIntentSupport() {
++ const key = oauth.getConfig().apiBase;
++ const st = _intentSupport.get(key);
++ if (!st) return { state: 'unknown' };
++ if (st.unsupportedUntil > Date.now()) return { state: 'unsupported', reason: st.reason, until: st.unsupportedUntil };
++ if (st.supported) return { state: 'supported' };
++ return { state: 'unknown' };
++}
function _mintHostConn(pcUsername) {
try {
const conn = db.getPowerchatConnectionByUsername(pcUsername);
@@@ -111,11 +127,14 @@ function _mintWarn(key, msg) {
console.warn(`[PowerChat] checkout intent for @${key} not minted — using canonical link: ${msg}`);
}
async function mintCheckoutLink(pcUsername, ref, { purpose = null, amountCents = null, itemName = null, returnTo = true } = {}) {
-- const fallback = () => ({ url: tipLinkFor(pcUsername, ref, { purpose, amountCents, returnTo }), minted: false });
++ const fallback = (reason) => ({ url: tipLinkFor(pcUsername, ref, { purpose, amountCents, returnTo }), minted: false, expiresAt: null, reason });
++ const apiBase = oauth.getConfig().apiBase;
++ const sup = _intentSupport.get(apiBase);
++ if (sup && sup.unsupportedUntil > Date.now()) return fallback('intents unsupported: ' + sup.reason);
const conn = _mintHostConn(pcUsername);
-- if (!conn) { _mintWarn(pcUsername, 'no connected account with checkout:attribute for that username'); return fallback(); }
++ if (!conn) { _mintWarn(pcUsername, 'no connected account with checkout:attribute for that username'); return fallback('no host token'); }
const cents = Math.round(Number(amountCents) || 0);
-- const base = { ref: String(ref) };
++ const base = { ref: String(ref).slice(0, 128) };
if (returnTo) base.redirect_uri = oauth.redirectUri();
if (purpose) base.purpose = String(purpose).slice(0, 64);
if (cents >= 50 && cents <= 1000000) base.amount_cents = String(cents);
@@@ -123,26 +142,41 @@ async function mintCheckoutLink(pcUsername, ref, { purpose = null, amountCents =
const attempt = async (withLabel) => {
const query = withLabel && label ? { ...base, item_name: label } : base;
const json = await oauth.apiRequest(conn.user_id, { method: 'GET', path: '/tip-checkout-link', username: pcUsername, query });
-- const url = (json && json.data && json.data.url) || (json && json.url) || null;
++ const d = (json && json.data && typeof json.data === 'object') ? json.data : (json || {});
++ const url = d.url || null;
if (!url || !/^https?:\/\//i.test(String(url))) throw new Error('response carried no url');
++ let out = String(url);
// PowerChat builds the link on ITS configured public host. When we talk to it
// through a different hostname (dev instance behind a tunnel), viewers can only
// reach the one we're configured with — the intent token is what matters, so
// re-home the link onto our baseUrl and keep path + query intact.
try {
-- const u = new URL(String(url)), b = new URL(oauth.getConfig().baseUrl);
++ const u = new URL(out), b = new URL(oauth.getConfig().baseUrl);
if (u.host !== b.host) { u.protocol = b.protocol; u.host = b.host; }
-- return u.toString();
-- } catch { return String(url); }
++ out = u.toString();
++ } catch { /* keep as-is */ }
++ return { url: out, expiresAt: d.expiresAt ? String(d.expiresAt) : null };
};
try {
-- return { url: await attempt(true), minted: true };
++ const r = await attempt(true);
++ _intentSupport.set(apiBase, { supported: true, unsupportedUntil: 0 });
++ return { url: r.url, minted: true, expiresAt: r.expiresAt };
} catch (e1) {
if (label && e1.status === 400) {
-- try { return { url: await attempt(false), minted: true }; } catch (e2) { e1 = e2; }
++ try {
++ const r = await attempt(false);
++ _intentSupport.set(apiBase, { supported: true, unsupportedUntil: 0 });
++ return { url: r.url, minted: true, expiresAt: r.expiresAt };
++ } catch (e2) { e1 = e2; }
++ }
++ if (e1.status === 404) {
++ // Endpoint absent on this PowerChat deployment — stop probing for a while.
++ _intentSupport.set(apiBase, { supported: false, unsupportedUntil: Date.now() + INTENT_UNSUPPORTED_MS, reason: 'tip-checkout-link returned 404 (not deployed on this PowerChat)' });
++ _mintWarn(pcUsername, `404 — checkout intents are not deployed on ${apiBase}; using canonical links for ${INTENT_UNSUPPORTED_MS / 60000} min`);
++ } else {
++ _mintWarn(pcUsername, `${e1.status || ''} ${e1.message}`.trim());
}
-- _mintWarn(pcUsername, `${e1.status || ''} ${e1.message}`.trim());
-- return fallback();
++ return fallback(`${e1.status || 'error'}: ${e1.message}`);
}
}
@@@ -157,7 +191,7 @@ async function buildPurchaseLink(order) {
purpose: 'vibes', amountCents: order.amount_cents, returnTo: true,
itemName: bucks ? `${bucks.toLocaleString()} Vibes` : 'Vibes',
});
-- return { url: link.url, mode: 'site', minted: link.minted };
++ return { url: link.url, mode: 'site', minted: link.minted, expiresAt: link.expiresAt };
}
// Subscription: streamer-direct when possible, site fallback otherwise.
@@@ -181,7 +215,7 @@ async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route
if (direct) {
db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
const link = await mintCheckoutLink(direct.powerchat_username, `pcsub:${order.id}`, opts);
-- return { url: link.url, mode: 'direct', minted: link.minted };
++ return { url: link.url, mode: 'direct', minted: link.minted, expiresAt: link.expiresAt };
}
if (route === 'direct') return null; // caller asked for the streamer's page and they have none
const site = getSiteAccount();
@@@ -189,7 +223,7 @@ async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route
const fee = Math.max(0, Math.round(Number(feeCents) || 0));
db.updatePaymentOrder(order.id, { provider_ref: `site${fee ? `:fee=${fee}` : ''}${suffix}` });
const link = await mintCheckoutLink(site.username, `pcsub:${order.id}`, opts);
-- return { url: link.url, mode: 'site', feeCents: fee, minted: link.minted };
++ return { url: link.url, mode: 'site', feeCents: fee, minted: link.minted, expiresAt: link.expiresAt };
}
/** Which PowerChat subscription routes a streamer currently supports (for the UI). */
@@@ -321,7 +355,8 @@ function _notify(userId, title, message) {
}
module.exports = {
-- isAvailable, getSiteAccount, tipLinkFor, mintCheckoutLink, subscribeRoutes,
++ isAvailable, getSiteAccount, tipLinkFor, mintCheckoutLink, checkoutIntentSupport, subscribeRoutes,
++ _test: { resetIntentSupport: () => _intentSupport.clear() },
buildPurchaseLink, buildSubscribeLink, buildDonateLink,
handleAttributedDonation,
};
ddiff --git a/server/monetization/payments-routes.js b/server/monetization/payments-routes.js
iindex 19822d4a..58959238 100644
---- a/server/monetization/payments-routes.js
++++ b/server/monetization/payments-routes.js
@@@ -66,7 +66,7 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
const link = await require('../integrations/powerchat-checkout').buildPurchaseLink(order);
if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat purchases are not available right now' }); }
return res.json({
-- url: link.url, powerchat: true, amountUsd,
++ url: link.url, powerchat: true, amountUsd, pinned: !!link.minted, expires_at: link.expiresAt || null,
note: `Complete the $${amountUsd.toFixed(2)} tip on PowerChat — your Vibes are credited automatically once it confirms.`,
});
}
@@@ -153,6 +153,7 @@ router.post('/subscribe', requireAuth, async (req, res) => {
const totalUsd = totalCents / 100;
return res.json({
url: link.url, powerchat: true, route: link.mode, amountUsd: totalUsd, feeUsd: feeCents / 100,
++ pinned: !!link.minted, expires_at: link.expiresAt || null,
note: link.mode === 'direct'
? `Tip $${totalUsd.toFixed(2)} on ${streamer.display_name || streamer.username}'s PowerChat — your subscription activates automatically once the tip confirms.`
: `Tip $${totalUsd.toFixed(2)} on OpenVibe's PowerChat (includes a $${(feeCents / 100).toFixed(2)} platform fee) — your subscription activates automatically once the tip confirms.`,
ddiff --git a/test/powerchat-checkout-intent.test.js b/test/powerchat-checkout-intent.test.js
nnew file mode 100644
iindex 00000000..d71ee113
---- /dev/null
++++ b/test/powerchat-checkout-intent.test.js
@@@ -0,0 +1,127 @@
++// Checkout-intent minting: success (re-homed URL, expiresAt), 404 → canonical fallback
++// with capability caching, 403 → per-call fallback, item_name 400 → retry without it,
++// no host token → canonical. DB and OAuth are stubbed; nothing touches the 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 };
++}
++
++// ── Stubs ──────────────────────────────────────────────────────
++const orders = new Map([[41, { id: 41, kind: 'bucks', amount_cents: 500, bucks: 500, user_id: 7, status: 'pending' }],
++ [42, { id: 42, kind: 'subscription', amount_cents: 500, user_id: 7, streamer_id: 1, status: 'pending' }]]);
++const conns = { alex: { user_id: 1, powerchat_username: 'alex', access_token: 'a', refresh_token: 'r', scope: 'profile:read checkout:attribute' },
++ noscope: { user_id: 2, powerchat_username: 'noscope', access_token: 'a', refresh_token: 'r', scope: 'profile:read' } };
++stub(path.join(__dirname, '../server/db/database'), {
++ getSetting: (k) => (k === 'powerchat_site_tip_username' ? 'alex' : null),
++ getPowerchatConnectionByUsername: (u) => conns[String(u).toLowerCase()] || null,
++ getPowerchatConnection: (id) => Object.values(conns).find((c) => c.user_id === id) || null,
++ getUserById: (id) => (id === 1 ? { id: 1, username: 'goosely', display_name: 'goosely' } : null),
++ getPaymentOrderById: (id) => orders.get(id) || null,
++ updatePaymentOrder: (id, f) => { Object.assign(orders.get(id), f); },
++});
++const api = { calls: [], script: [] };
++stub(path.join(__dirname, '../server/integrations/powerchat-oauth'), {
++ getConfig: () => ({ enabled: true, clientId: 'pca_test', baseUrl: 'https://tunnel.example', apiBase: 'https://tunnel.example/api/dev/v1' }),
++ redirectUri: () => 'https://openvibe.live/api/powerchat/oauth/callback',
++ apiRequest: async (userId, req) => {
++ api.calls.push({ userId, ...req });
++ const next = api.script.shift();
++ if (!next) throw new Error('unscripted apiRequest');
++ if (next.throw) throw Object.assign(new Error(next.message || 'fail'), { status: next.throw });
++ return next.body;
++ },
++});
++
++const checkout = require('../server/integrations/powerchat-checkout');
++const mint = (opts) => checkout.mintCheckoutLink('alex', 'pcsub:42', { purpose: 'subscription', amountCents: 500, itemName: 'goosely — 1 month subscription', ...opts });
++
++(async () => {
++ // ── success: intent URL, re-homed onto our base host, expiresAt surfaced ──
++ api.script.push({ body: { data: { url: 'https://powerchat.internal/alex/tip?app_intent=TOK1', expiresAt: '2026-08-27T11:00:00.000Z' } } });
++ let r = await mint();
++ assert.strictEqual(r.minted, true);
++ assert.strictEqual(r.url, 'https://tunnel.example/alex/tip?app_intent=TOK1', 'host swapped to the base we can reach, token intact');
++ assert.strictEqual(r.expiresAt, '2026-08-27T11:00:00.000Z');
++ let q = api.calls.at(-1);
++ assert.strictEqual(q.path, '/tip-checkout-link');
++ assert.strictEqual(q.username, 'alex');
++ assert.strictEqual(q.userId, 1, 'minted with the host account token');
++ assert.deepStrictEqual(q.query, { ref: 'pcsub:42', redirect_uri: 'https://openvibe.live/api/powerchat/oauth/callback', purpose: 'subscription', amount_cents: '500', item_name: 'goosely — 1 month subscription' });
++ assert.deepStrictEqual(checkout.checkoutIntentSupport(), { state: 'supported' });
++ assert.ok(!/app_amount_cents|app_ref/.test(r.url), 'terms never ride in the URL');
++
++ // ── envelope tolerance: bare { url } (older deployment) ──
++ api.script.push({ body: { url: 'https://tunnel.example/alex/tip?app_intent=TOK2' } });
++ r = await mint();
++ assert.strictEqual(r.minted, true); assert.match(r.url, /app_intent=TOK2/); assert.strictEqual(r.expiresAt, null);
++
++ // ── item_name rejected (400) → retried once without it, still minted ──
++ api.calls.length = 0;
++ api.script.push({ throw: 400, message: 'unknown query parameter item_name' }, { body: { data: { url: 'https://tunnel.example/alex/tip?app_intent=TOK3' } } });
++ r = await mint();
++ assert.strictEqual(r.minted, true); assert.match(r.url, /TOK3/);
++ assert.strictEqual(api.calls.length, 2);
++ assert.ok('item_name' in api.calls[0].query && !('item_name' in api.calls[1].query), 'second try drops item_name only');
++
++ // ── 403 (scope shrunk / redirect not registered) → canonical fallback, no caching ──
++ api.calls.length = 0;
++ api.script.push({ throw: 403, message: 'redirect_uri is not registered for this app' }, { throw: 403, message: 'again' });
++ r = await mint();
++ assert.strictEqual(r.minted, false);
++ assert.match(r.reason, /403/);
++ const u = new URL(r.url);
++ assert.strictEqual(u.origin + u.pathname, 'https://tunnel.example/alex/tip');
++ assert.strictEqual(u.searchParams.get('app_client_id'), 'pca_test');
++ assert.strictEqual(u.searchParams.get('app_ref'), 'pcsub:42');
++ assert.strictEqual(u.searchParams.get('app_amount_cents'), '500', 'fallback still pins via the canonical param');
++ assert.strictEqual(u.searchParams.get('app_purpose'), 'subscription');
++ assert.strictEqual(u.searchParams.get('app_redirect_uri'), 'https://openvibe.live/api/powerchat/oauth/callback');
++ assert.strictEqual(api.calls.length, 1, '403 with a label is not retried (only 400 is)');
++ assert.strictEqual(checkout.checkoutIntentSupport().state, 'supported', 'a 403 is per-grant, not a deployment gate');
++ api.script.length = 0;
++
++ // ── 404 (endpoint not deployed) → canonical fallback AND no re-probe for a while ──
++ api.calls.length = 0;
++ api.script.push({ throw: 404, message: 'Not found' });
++ r = await mint();
++ assert.strictEqual(r.minted, false);
++ assert.match(new URL(r.url).searchParams.get('app_amount_cents'), /^500$/);
++ assert.strictEqual(checkout.checkoutIntentSupport().state, 'unsupported');
++ r = await mint(); // second order: no API call at all
++ assert.strictEqual(r.minted, false);
++ assert.match(r.reason, /unsupported/);
++ assert.strictEqual(api.calls.length, 1, '404 is cached — one probe, not one per order');
++ checkout._test.resetIntentSupport();
++
++ // ── no usable host token → canonical link without any API call ──
++ api.calls.length = 0;
++ r = await checkout.mintCheckoutLink('noscope', 'pcorder:41', { purpose: 'vibes', amountCents: 500 });
++ assert.strictEqual(r.minted, false);
++ assert.strictEqual(api.calls.length, 0);
++ assert.match(r.url, /^https:\/\/tunnel\.example\/noscope\/tip\?/);
++ r = await checkout.mintCheckoutLink('stranger', 'pcorder:41', { purpose: 'vibes', amountCents: 500 });
++ assert.strictEqual(r.minted, false);
++
++ // ── amount below PowerChat's floor (50¢) is left free rather than rejected ──
++ api.script.push({ body: { data: { url: 'https://tunnel.example/alex/tip?app_intent=TOK4' } } });
++ r = await mint({ amountCents: 10 });
++ assert.ok(!('amount_cents' in api.calls.at(-1).query));
++
++ // ── builders: subscription persists route + renew flag and returns expiry ──
++ api.script.push({ body: { data: { url: 'https://tunnel.example/alex/tip?app_intent=TOK5', expiresAt: '2026-08-27T12:00:00.000Z' } } });
++ const sub = await checkout.buildSubscribeLink(orders.get(42), 1, { autoRenew: 1, route: 'direct' });
++ assert.strictEqual(sub.mode, 'direct'); assert.strictEqual(sub.minted, true); assert.strictEqual(sub.expiresAt, '2026-08-27T12:00:00.000Z');
++ assert.strictEqual(orders.get(42).provider_ref, 'direct:renew');
++ assert.strictEqual(api.calls.at(-1).query.ref, 'pcsub:42');
++ assert.strictEqual(api.calls.at(-1).query.item_name, 'goosely — 1 month subscription (auto-renew)');
++ api.script.push({ body: { data: { url: 'https://tunnel.example/alex/tip?app_intent=TOK6' } } });
++ const buy = await checkout.buildPurchaseLink(orders.get(41));
++ assert.strictEqual(buy.mode, 'site'); assert.strictEqual(buy.minted, true);
++ assert.strictEqual(api.calls.at(-1).query.item_name, '500 Vibes');
++ assert.strictEqual(api.calls.at(-1).query.purpose, 'vibes');
++
++ console.log('✅ powerchat checkout-intent tests passed');
++})().catch((e) => { console.error('❌', e); process.exit(1); });