openvibers/openvibe.live Commits
PowerChat: gate checkout intents behind a capability check; surface expiresAt
Changes
@@@ -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, routeif (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 noneconst site = getSiteAccount();@@@ -189,7 +223,7 @@ async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, routeconst 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,};@@@ -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.`,nnew file mode 100644@@@ -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); });
@@ -81,20 +81,36 @@ function tipLinkFor(pcUsername, ref, { purpose = null, amountCents = null, retur
@@ -81,20 +81,36 @@ function tipLinkFor(pcUsername, ref, { purpose = null, amountCents = null, retur
// ── Server-minted checkout intents ───────────────────────────
// ── Server-minted checkout intents ───────────────────────────
// PowerChat's fixed-price checkout is an INTENT, not a query string: the app calls
// 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
// 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
// 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
// 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
// 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
// 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
// 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) {
function _mintHostConn(pcUsername) {
try {
try {
const conn = db.getPowerchatConnectionByUsername(pcUsername);
const conn = db.getPowerchatConnectionByUsername(pcUsername);
@@ -111,11 +127,14 @@ function _mintWarn(key, msg) {
@@ -111,11 +127,14 @@ function _mintWarn(key, msg) {
console.warn(`[PowerChat] checkout intent for @${key} not minted — using canonical link: ${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 } = {}) {
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);
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 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 (returnTo) base.redirect_uri = oauth.redirectUri();
if (purpose) base.purpose = String(purpose).slice(0, 64);
if (purpose) base.purpose = String(purpose).slice(0, 64);
if (cents >= 50 && cents <= 1000000) base.amount_cents = String(cents);
if (cents >= 50 && cents <= 1000000) base.amount_cents = String(cents);
@@ -123,26 +142,41 @@ async function mintCheckoutLink(pcUsername, ref, { purpose = null, amountCents =
@@ -123,26 +142,41 @@ async function mintCheckoutLink(pcUsername, ref, { purpose = null, amountCents =
const attempt = async (withLabel) => {
const attempt = async (withLabel) => {
const query = withLabel && label ? { ...base, item_name: label } : base;
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 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');
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
// 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
// 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
// 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.
// re-home the link onto our baseUrl and keep path + query intact.
try {
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; }
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 {
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) {
} catch (e1) {
if (label && e1.status === 400) {
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) {
@@ -157,7 +191,7 @@ async function buildPurchaseLink(order) {
purpose: 'vibes', amountCents: order.amount_cents, returnTo: true,
purpose: 'vibes', amountCents: order.amount_cents, returnTo: true,
itemName: bucks ? `${bucks.toLocaleString()} Vibes` : 'Vibes',
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.
// Subscription: streamer-direct when possible, site fallback otherwise.
@@ -181,7 +215,7 @@ async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route
@@ -181,7 +215,7 @@ async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route
if (direct) {
if (direct) {
db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
const link = await mintCheckoutLink(direct.powerchat_username, `pcsub:${order.id}`, opts);
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
if (route === 'direct') return null; // caller asked for the streamer's page and they have none
const site = getSiteAccount();
const site = getSiteAccount();
@@ -189,7 +223,7 @@ async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route
@@ -189,7 +223,7 @@ async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route
const fee = Math.max(0, Math.round(Number(feeCents) || 0));
const fee = Math.max(0, Math.round(Number(feeCents) || 0));
db.updatePaymentOrder(order.id, { provider_ref: `site${fee ? `:fee=${fee}` : ''}${suffix}` });
db.updatePaymentOrder(order.id, { provider_ref: `site${fee ? `:fee=${fee}` : ''}${suffix}` });
const link = await mintCheckoutLink(site.username, `pcsub:${order.id}`, opts);
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). */
/** Which PowerChat subscription routes a streamer currently supports (for the UI). */
@@ -321,7 +355,8 @@ function _notify(userId, title, message) {
@@ -321,7 +355,8 @@ function _notify(userId, title, message) {
}
}
module.exports = {
module.exports = {
- isAvailable, getSiteAccount, tipLinkFor, mintCheckoutLink, subscribeRoutes,
+ isAvailable, getSiteAccount, tipLinkFor, mintCheckoutLink, checkoutIntentSupport, subscribeRoutes,
+ _test: { resetIntentSupport: () => _intentSupport.clear() },
buildPurchaseLink, buildSubscribeLink, buildDonateLink,
buildPurchaseLink, buildSubscribeLink, buildDonateLink,
handleAttributedDonation,
handleAttributedDonation,
};
};
@@ -66,7 +66,7 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
@@ -66,7 +66,7 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
const link = await require('../integrations/powerchat-checkout').buildPurchaseLink(order);
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' }); }
if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat purchases are not available right now' }); }
return res.json({
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.`,
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) => {
@@ -153,6 +153,7 @@ router.post('/subscribe', requireAuth, async (req, res) => {
const totalUsd = totalCents / 100;
const totalUsd = totalCents / 100;
return res.json({
return res.json({
url: link.url, powerchat: true, route: link.mode, amountUsd: totalUsd, feeUsd: feeCents / 100,
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'
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 ${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.`,
: `Tip $${totalUsd.toFixed(2)} on OpenVibe's PowerChat (includes a $${(feeCents / 100).toFixed(2)} platform fee) — your subscription activates automatically once the tip confirms.`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -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); });