openvibers/openvibe.live Commits
Donations are sent once: a second click while one is on its way does nothing, and each carries an Idempotency-Key that a retry after no answer reuses; api() adds caller headers to its defaults (passing headers replaced them, Authorization included) (#168)
Changes
@@@ -219,10 +219,12 @@ function authHeaders() {}async function api(path, opts = {}) {++ // Caller headers add to the defaults (spreading opts after them used to replace the lot, auth included).++ const { headers, body, _retried, ...init } = opts;const res = await fetch(`${API}/api${path}`, {-- headers: { 'Content-Type': 'application/json', ...authHeaders(), ...opts.headers },-- ...opts,-- body: opts.body ? (typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) : undefined,++ ...init,++ headers: { 'Content-Type': 'application/json', ...authHeaders(), ...headers },++ body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,});// Auto-refresh on 401 (expired JWT) — try once@@@ -226,7 +226,7 @@ function openvibeBucksDonateModal() {</div><div class="muted" id="donate-balance-line" style="font-size:0.82rem;margin:2px 0 6px"></div>-- <button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:4px">++ <button class="btn btn-primary btn-lg" id="modal-donate-send" onclick="doDonate()" style="width:100%;margin-top:4px"><i class="fa-solid fa-coins"></i> Donate Vibes</button><button class="btn btn-outline" onclick="showModal('buy-funds')" style="width:100%;margin-top:8px">@@@ -346,16 +346,36 @@ async function doDonate() {const goalId = goalSel && goalSel.value ? parseInt(goalSel.value, 10) : null;if (!amount || amount < 1) return toast('Enter a valid amount', 'error');--++ if (_donateInFlight) return;++++ const body = { streamer_id: streamerId, stream_id: (typeof currentStreamId !== 'undefined' ? currentStreamId : null) || null, amount: Math.round(amount), message, goal_id: goalId };++ // One Idempotency-Key per donation: a second click while it is on its way does nothing, and trying the++ // same donation again after an unanswered request reuses the key, so the server sends it at most once.++ const sig = JSON.stringify(body);++ if (!_donateAttempt || _donateAttempt.sig !== sig) _donateAttempt = { sig, key: _newRequestKey() };++ const btn = document.getElementById('modal-donate-send');++ _donateInFlight = true;++ if (btn) btn.disabled = true;try {-- await api('/funds/donate', {-- method: 'POST',-- body: { streamer_id: streamerId, stream_id: (typeof currentStreamId !== 'undefined' ? currentStreamId : null) || null, amount: Math.round(amount), message, goal_id: goalId }-- });++ await api('/funds/donate', { method: 'POST', headers: { 'Idempotency-Key': _donateAttempt.key }, body });++ _donateAttempt = null;toast(`Donated ${Math.round(amount).toLocaleString()} Vibes!`, 'success');loadBalance();closeModal();-- } catch (e) { toast(e.message || 'Donation failed', 'error'); }++ } catch (e) {++ // A refusal (no Vibes, bad amount) ends this attempt; no answer, or a 5xx, may have sent it: keep the key.++ if (e && e.status >= 400 && e.status < 500) _donateAttempt = null;++ toast(e.message || 'Donation failed', 'error');++ } finally {++ _donateInFlight = false;++ if (btn) btn.disabled = false;++ }++}++let _donateInFlight = false;++let _donateAttempt = null;++function _newRequestKey() {++ if (window.crypto && typeof crypto.randomUUID === 'function') return `donate-${crypto.randomUUID()}`;++ return `donate-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;}/**nnew file mode 100644@@@ -0,0 +1,96 @@++/**++ * A donation is sent once (public/js/vibes.js doDonate): a second click while the first is on its way does nothing,++ * and every request carries an Idempotency-Key, reused when the same donation is tried again after an unanswered++ * request (OpenVibe.Billing replays a known key instead of moving the Vibes twice). The SPA's api() helper++ * (public/js/app.js) adds caller headers to its defaults; it used to replace them, dropping Authorization.++ *++ * node test/donate-idempotency.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const read = (f) => fs.readFileSync(path.join(__dirname, '..', 'public', 'js', f), 'utf8');++const fnSource = (src, name) => {++ const start = src.indexOf(`function ${name}(`);++ assert.ok(start >= 0, `${name} exists`);++ // A top-level function ends at the first line that is only a closing brace.++ return src.slice(src.lastIndexOf('\n', start) + 1, src.indexOf('\n}\n', start) + 2);++};++++(async () => {++ // api(): caller headers join the defaults.++ const appSrc = read('app.js');++ const fetched = [];++ // eslint-disable-next-line no-new-func++ const api = new Function('fetch', 'API', 'authHeaders', 'tryRefreshToken', `${fnSource(appSrc, 'api')}; return api;`)(++ async (url, init) => { fetched.push({ url, init }); return { ok: true, status: 200, json: async () => ({ ok: true }) }; },++ '', () => ({ Authorization: 'Bearer t' }), async () => false);++ await api('/funds/donate', { method: 'POST', headers: { 'Idempotency-Key': 'k1' }, body: { a: 1 } });++ assert.deepStrictEqual(fetched[0].init.headers, { 'Content-Type': 'application/json', Authorization: 'Bearer t', 'Idempotency-Key': 'k1' });++ assert.strictEqual(fetched[0].init.method, 'POST');++ assert.strictEqual(fetched[0].init.body, '{"a":1}');++ assert.ok(!('_retried' in fetched[0].init), 'internal flags stay out of fetch');++++ // doDonate(): one in flight, one key per donation.++ const vibesSrc = read('vibes.js');++ const start = vibesSrc.indexOf('async function doDonate(');++ const end = vibesSrc.indexOf('\n}\n', vibesSrc.indexOf('function _newRequestKey(')) + 3;++ const fields = { 'modal-donate-amount': { value: '250' }, 'modal-donate-message': { value: 'gg' }, 'modal-donate-goal': { value: '' }, 'modal-donate-send': { disabled: false } };++ const calls = [];++ let answer = null;++ const toasts = [];++ const env = {++ currentUser: { id: 1 },++ document: { getElementById: (id) => fields[id] || null },++ window: { crypto: { randomUUID: (() => { let n = 0; return () => `uuid-${++n}`; })() } },++ crypto: null,++ api: (p, o) => { calls.push({ p, o, disabled: fields['modal-donate-send'].disabled }); return answer(); },++ toast: (m, k) => toasts.push(k),++ showModal: () => {}, closeModal: () => {}, loadBalance: () => {},++ _donateStreamerId: () => 7,++ };++ env.crypto = env.window.crypto;++ // eslint-disable-next-line no-new-func++ const doDonate = new Function(...Object.keys(env), `${vibesSrc.slice(start, end)}; return doDonate;`)(...Object.values(env));++++ let release;++ answer = () => new Promise((resolve) => { release = resolve; });++ const first = doDonate();++ await doDonate();++ assert.strictEqual(calls.length, 1, 'a second click while the first is on its way sends nothing');++ assert.strictEqual(calls[0].disabled, true, 'the button is disabled while it is on its way');++ release({ success: true });++ await first;++ assert.strictEqual(fields['modal-donate-send'].disabled, false);++ const k1 = calls[0].o.headers['Idempotency-Key'];++ assert.match(k1, /^donate-uuid-/);++++ answer = async () => { throw new TypeError('Failed to fetch'); };++ await doDonate();++ const k2 = calls[1].o.headers['Idempotency-Key'];++ assert.notStrictEqual(k2, k1, 'a new donation after a sent one: a new key');++ answer = async () => { throw { status: 502, message: 'Bad gateway' }; };++ await doDonate();++ assert.strictEqual(calls[2].o.headers['Idempotency-Key'], k2, 'no answer, then a 502: trying again reuses the key');++ answer = async () => ({ success: true });++ await doDonate();++ assert.strictEqual(calls[3].o.headers['Idempotency-Key'], k2, 'and the retry that lands still uses it');++++ answer = async () => { throw { status: 400, message: 'Insufficient Vibes' }; };++ await doDonate();++ const k3 = calls[4].o.headers['Idempotency-Key'];++ answer = async () => ({ success: true });++ await doDonate();++ assert.notStrictEqual(calls[5].o.headers['Idempotency-Key'], k3, 'a refused donation is over: trying again is a new request');++++ answer = async () => { throw new TypeError('Failed to fetch'); };++ await doDonate();++ fields['modal-donate-amount'].value = '300';++ answer = async () => ({ success: true });++ await doDonate();++ assert.notStrictEqual(calls[7].o.headers['Idempotency-Key'], calls[6].o.headers['Idempotency-Key'], 'a different amount is a different donation');++ assert.strictEqual(calls[7].o.body.amount, 300);++ console.log('donate idempotency: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -219,10 +219,12 @@ function authHeaders() {
@@ -219,10 +219,12 @@ function authHeaders() {
}
}
async function api(path, opts = {}) {
async function api(path, opts = {}) {
+ // Caller headers add to the defaults (spreading opts after them used to replace the lot, auth included).
+ const { headers, body, _retried, ...init } = opts;
const res = await fetch(`${API}/api${path}`, {
const res = await fetch(`${API}/api${path}`, {
- headers: { 'Content-Type': 'application/json', ...authHeaders(), ...opts.headers },
- ...opts,
- body: opts.body ? (typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)) : undefined,
+ ...init,
+ headers: { 'Content-Type': 'application/json', ...authHeaders(), ...headers },
+ body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined,
});
});
// Auto-refresh on 401 (expired JWT) — try once
// Auto-refresh on 401 (expired JWT) — try once
@@ -226,7 +226,7 @@ function openvibeBucksDonateModal() {
@@ -226,7 +226,7 @@ function openvibeBucksDonateModal() {
</div>
</div>
<div class="muted" id="donate-balance-line" style="font-size:0.82rem;margin:2px 0 6px"></div>
<div class="muted" id="donate-balance-line" style="font-size:0.82rem;margin:2px 0 6px"></div>
- <button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:4px">
+ <button class="btn btn-primary btn-lg" id="modal-donate-send" onclick="doDonate()" style="width:100%;margin-top:4px">
<i class="fa-solid fa-coins"></i> Donate Vibes
<i class="fa-solid fa-coins"></i> Donate Vibes
</button>
</button>
<button class="btn btn-outline" onclick="showModal('buy-funds')" style="width:100%;margin-top:8px">
<button class="btn btn-outline" onclick="showModal('buy-funds')" style="width:100%;margin-top:8px">
@@ -346,16 +346,36 @@ async function doDonate() {
@@ -346,16 +346,36 @@ async function doDonate() {
const goalId = goalSel && goalSel.value ? parseInt(goalSel.value, 10) : null;
const goalId = goalSel && goalSel.value ? parseInt(goalSel.value, 10) : null;
if (!amount || amount < 1) return toast('Enter a valid amount', 'error');
if (!amount || amount < 1) return toast('Enter a valid amount', 'error');
-
+ if (_donateInFlight) return;
+
+ const body = { streamer_id: streamerId, stream_id: (typeof currentStreamId !== 'undefined' ? currentStreamId : null) || null, amount: Math.round(amount), message, goal_id: goalId };
+ // One Idempotency-Key per donation: a second click while it is on its way does nothing, and trying the
+ // same donation again after an unanswered request reuses the key, so the server sends it at most once.
+ const sig = JSON.stringify(body);
+ if (!_donateAttempt || _donateAttempt.sig !== sig) _donateAttempt = { sig, key: _newRequestKey() };
+ const btn = document.getElementById('modal-donate-send');
+ _donateInFlight = true;
+ if (btn) btn.disabled = true;
try {
try {
- await api('/funds/donate', {
- method: 'POST',
- body: { streamer_id: streamerId, stream_id: (typeof currentStreamId !== 'undefined' ? currentStreamId : null) || null, amount: Math.round(amount), message, goal_id: goalId }
- });
+ await api('/funds/donate', { method: 'POST', headers: { 'Idempotency-Key': _donateAttempt.key }, body });
+ _donateAttempt = null;
toast(`Donated ${Math.round(amount).toLocaleString()} Vibes!`, 'success');
toast(`Donated ${Math.round(amount).toLocaleString()} Vibes!`, 'success');
loadBalance();
loadBalance();
closeModal();
closeModal();
- } catch (e) { toast(e.message || 'Donation failed', 'error'); }
+ } catch (e) {
+ // A refusal (no Vibes, bad amount) ends this attempt; no answer, or a 5xx, may have sent it: keep the key.
+ if (e && e.status >= 400 && e.status < 500) _donateAttempt = null;
+ toast(e.message || 'Donation failed', 'error');
+ } finally {
+ _donateInFlight = false;
+ if (btn) btn.disabled = false;
+ }
+}
+let _donateInFlight = false;
+let _donateAttempt = null;
+function _newRequestKey() {
+ if (window.crypto && typeof crypto.randomUUID === 'function') return `donate-${crypto.randomUUID()}`;
+ return `donate-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
}
}
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+/**
+ * A donation is sent once (public/js/vibes.js doDonate): a second click while the first is on its way does nothing,
+ * and every request carries an Idempotency-Key, reused when the same donation is tried again after an unanswered
+ * request (OpenVibe.Billing replays a known key instead of moving the Vibes twice). The SPA's api() helper
+ * (public/js/app.js) adds caller headers to its defaults; it used to replace them, dropping Authorization.
+ *
+ * node test/donate-idempotency.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const read = (f) => fs.readFileSync(path.join(__dirname, '..', 'public', 'js', f), 'utf8');
+const fnSource = (src, name) => {
+ const start = src.indexOf(`function ${name}(`);
+ assert.ok(start >= 0, `${name} exists`);
+ // A top-level function ends at the first line that is only a closing brace.
+ return src.slice(src.lastIndexOf('\n', start) + 1, src.indexOf('\n}\n', start) + 2);
+};
+
+(async () => {
+ // api(): caller headers join the defaults.
+ const appSrc = read('app.js');
+ const fetched = [];
+ // eslint-disable-next-line no-new-func
+ const api = new Function('fetch', 'API', 'authHeaders', 'tryRefreshToken', `${fnSource(appSrc, 'api')}; return api;`)(
+ async (url, init) => { fetched.push({ url, init }); return { ok: true, status: 200, json: async () => ({ ok: true }) }; },
+ '', () => ({ Authorization: 'Bearer t' }), async () => false);
+ await api('/funds/donate', { method: 'POST', headers: { 'Idempotency-Key': 'k1' }, body: { a: 1 } });
+ assert.deepStrictEqual(fetched[0].init.headers, { 'Content-Type': 'application/json', Authorization: 'Bearer t', 'Idempotency-Key': 'k1' });
+ assert.strictEqual(fetched[0].init.method, 'POST');
+ assert.strictEqual(fetched[0].init.body, '{"a":1}');
+ assert.ok(!('_retried' in fetched[0].init), 'internal flags stay out of fetch');
+
+ // doDonate(): one in flight, one key per donation.
+ const vibesSrc = read('vibes.js');
+ const start = vibesSrc.indexOf('async function doDonate(');
+ const end = vibesSrc.indexOf('\n}\n', vibesSrc.indexOf('function _newRequestKey(')) + 3;
+ const fields = { 'modal-donate-amount': { value: '250' }, 'modal-donate-message': { value: 'gg' }, 'modal-donate-goal': { value: '' }, 'modal-donate-send': { disabled: false } };
+ const calls = [];
+ let answer = null;
+ const toasts = [];
+ const env = {
+ currentUser: { id: 1 },
+ document: { getElementById: (id) => fields[id] || null },
+ window: { crypto: { randomUUID: (() => { let n = 0; return () => `uuid-${++n}`; })() } },
+ crypto: null,
+ api: (p, o) => { calls.push({ p, o, disabled: fields['modal-donate-send'].disabled }); return answer(); },
+ toast: (m, k) => toasts.push(k),
+ showModal: () => {}, closeModal: () => {}, loadBalance: () => {},
+ _donateStreamerId: () => 7,
+ };
+ env.crypto = env.window.crypto;
+ // eslint-disable-next-line no-new-func
+ const doDonate = new Function(...Object.keys(env), `${vibesSrc.slice(start, end)}; return doDonate;`)(...Object.values(env));
+
+ let release;
+ answer = () => new Promise((resolve) => { release = resolve; });
+ const first = doDonate();
+ await doDonate();
+ assert.strictEqual(calls.length, 1, 'a second click while the first is on its way sends nothing');
+ assert.strictEqual(calls[0].disabled, true, 'the button is disabled while it is on its way');
+ release({ success: true });
+ await first;
+ assert.strictEqual(fields['modal-donate-send'].disabled, false);
+ const k1 = calls[0].o.headers['Idempotency-Key'];
+ assert.match(k1, /^donate-uuid-/);
+
+ answer = async () => { throw new TypeError('Failed to fetch'); };
+ await doDonate();
+ const k2 = calls[1].o.headers['Idempotency-Key'];
+ assert.notStrictEqual(k2, k1, 'a new donation after a sent one: a new key');
+ answer = async () => { throw { status: 502, message: 'Bad gateway' }; };
+ await doDonate();
+ assert.strictEqual(calls[2].o.headers['Idempotency-Key'], k2, 'no answer, then a 502: trying again reuses the key');
+ answer = async () => ({ success: true });
+ await doDonate();
+ assert.strictEqual(calls[3].o.headers['Idempotency-Key'], k2, 'and the retry that lands still uses it');
+
+ answer = async () => { throw { status: 400, message: 'Insufficient Vibes' }; };
+ await doDonate();
+ const k3 = calls[4].o.headers['Idempotency-Key'];
+ answer = async () => ({ success: true });
+ await doDonate();
+ assert.notStrictEqual(calls[5].o.headers['Idempotency-Key'], k3, 'a refused donation is over: trying again is a new request');
+
+ answer = async () => { throw new TypeError('Failed to fetch'); };
+ await doDonate();
+ fields['modal-donate-amount'].value = '300';
+ answer = async () => ({ success: true });
+ await doDonate();
+ assert.notStrictEqual(calls[7].o.headers['Idempotency-Key'], calls[6].o.headers['Idempotency-Key'], 'a different amount is a different donation');
+ assert.strictEqual(calls[7].o.body.amount, 300);
+ console.log('donate idempotency: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });