openvibers/openvibe.billing Commits

The staff console shows what shipped on Billing, server-rendered (it loads no scripts, so the shared widget cannot run there): the newest network-changelog entry, read from Network's loopback proxy at most once a minute in the background, escaped, linked to the network's update log; nothing until an entry has been read

OP OpenVibers committed 24 Sept 2026 · af1d30e3f7 · 3 files, +56 −3


Changes

ddiff --git a/server/console/index.js b/server/console/index.js
iindex c7a51ff..572e3a7 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -42,7 +42,7 @@ const { createSessions, sameString, random } = require('./session');
const sso = require('./sso');
const audit = require('./audit');
const q = require('./queries');
--const { CSP, pages } = require('./views');
++const { CSP, pages, setShipped } = require('./views');
/** Server-side mapping: what a staff session holds, in the API's own capability ids. */
const STAFF_CAPABILITIES = Object.freeze([CAP.cashoutManage, CAP.admin]);
@@@ -73,6 +73,21 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
const r = express.Router();
const baseOrigin = (() => { try { return new URL(config.baseUrl).origin; } catch { return null; } })();
++ // The footer's "shipped" line: read the network changelog (Network's loopback proxy) at most once a
++ // minute, in the background of a request; the page never waits for it and a failure shows nothing.
++ let shippedReadAt = 0;
++ r.use((req, res, next) => {
++ const now = Date.now();
++ if (now - shippedReadAt > 60 * 1000 && config.network && config.network.internalUrl) {
++ shippedReadAt = now;
++ Promise.resolve(fetchImpl(`${config.network.internalUrl}/api/v1/changelog?service=billing&limit=1`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(2000) }))
++ .then((x) => (x && x.ok ? x.json() : null))
++ .then((d) => { if (d && Array.isArray(d.entries) && d.entries[0]) setShipped(d.entries[0]); })
++ .catch(() => { /* the line just stays as it was */ });
++ }
++ next();
++ });
++
let secret = cc.sessionSecret;
let unavailable = null;
if (!secret || secret.length < 32) {
ddiff --git a/server/console/views.js b/server/console/views.js
iindex 3f33548..1b67e8c 100644
---- a/server/console/views.js
++++ b/server/console/views.js
@@@ -55,6 +55,7 @@ fieldset{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margi
legend{padding:0 6px;font-weight:600}
dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:0}dt{color:var(--muted)}dd{margin:0}
.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;align-items:start}
++.shipped{max-width:1180px;margin:24px auto;padding:0 16px 24px;color:var(--muted);font-size:.85rem}
`;
const CSS_HASH = `sha256-${crypto.createHash('sha256').update(CSS).digest('base64')}`;
const CSP = `default-src 'none'; style-src '${CSS_HASH}'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`;
@@@ -81,6 +82,25 @@ const NAV = [
['/import-holds', 'Import holds', 'holds'], ['/reconciliation', 'Reconciliation', 'reconciliation'], ['/freeze', 'Freeze', 'freeze'], ['/audit', 'Audit log', 'audit'],
];
++// What shipped on OpenVibe.Billing: the network changelog's newest entry, rendered here as plain HTML
++// (this console loads no scripts, so the shared "shipped" widget cannot run). The console router
++// refreshes it from Network's loopback proxy; nothing is shown until an entry has been read.
++let _shipped = null;
++function setShipped(e) {
++ if (e && e.subject && e.deployed_at && !Number.isNaN(Date.parse(e.deployed_at))) _shipped = { subject: String(e.subject).slice(0, 200), deployed_at: e.deployed_at };
++}
++function agoText(iso, now = Date.now()) {
++ const s = Math.max(0, (now - Date.parse(iso)) / 1000);
++ if (s < 45) return 'just now';
++ if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m ago`;
++ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
++ return `${Math.round(s / 86400)}d ago`;
++}
++function shippedLine() {
++ if (!_shipped) return '';
++ return html`<footer class="shipped">Billing shipped <time datetime="${_shipped.deployed_at}">${agoText(_shipped.deployed_at)}</time>: ${_shipped.subject} · <a href="https://openvibe.network/updates?site=billing">Updates</a></footer>`;
++}
++
function layout({ title, section, staff, csrf, frozen, notice, error, body }) {
return `<!doctype html>${render(html`<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer"><title>${title} · Billing staff</title><style>${new Raw(CSS)}</style></head>
@@@ -93,7 +113,7 @@ ${frozen ? html`<div class="banner frozen"><strong>The economy is frozen.</stron
${notice ? html`<div class="banner done">${notice}</div>` : ''}
${error ? html`<div class="banner error"><strong>Refused:</strong> ${error}</div>` : ''}
${body}
--</main></body></html>`)}`;
++</main>${shippedLine()}</body></html>`)}`;
}
// ── Pages ────────────────────────────────────────────────────
@@@ -278,4 +298,4 @@ ${rows.length ? auditTable(rows) : html`<div class="card muted">Empty.</div>`}`,
});
}
--module.exports = { CSP, html, esc, pages: { signIn, message, dashboard, cashouts, cashout, receipts, holds, reconciliation, reconciliationRun, freeze, audit } };
++module.exports = { CSP, html, esc, setShipped, pages: { signIn, message, dashboard, cashouts, cashout, receipts, holds, reconciliation, reconciliationRun, freeze, audit } };
ddiff --git a/test/console-shipped.test.js b/test/console-shipped.test.js
nnew file mode 100644
iindex 0000000..c22c534
---- /dev/null
++++ b/test/console-shipped.test.js
@@@ -0,0 +1,18 @@
++'use strict';
++// The staff console shows what shipped on Billing as plain server-rendered HTML (it loads no
++// scripts): escaped, linked to the network's update log, and absent until an entry has been read.
++const assert = require('assert');
++const views = require('../server/console/views');
++
++const before = views.pages.message({ title: 'Hi', text: 'x' });
++assert.ok(!before.includes('class="shipped"'), 'nothing until the changelog has been read');
++views.setShipped({ subject: 'Cashout <script>alert(1)</script> fix', deployed_at: new Date(Date.now() - 5 * 60e3).toISOString() });
++const page = views.pages.message({ title: 'Hi', text: 'x' });
++assert.ok(page.includes('Billing shipped <time'), 'the line');
++assert.ok(page.includes('5m ago'));
++assert.ok(page.includes('Cashout &lt;script&gt;alert(1)&lt;/script&gt; fix') && !page.includes('<script>alert'), 'escaped');
++assert.ok(page.includes('href="https://openvibe.network/updates?site=billing"'));
++assert.ok(!/<script/i.test(page.replace('&lt;script&gt;', '')), 'still no scripts on the console');
++views.setShipped({ subject: '', deployed_at: 'nope' });
++assert.ok(views.pages.message({ title: 'Hi', text: 'x' }).includes('Cashout'), 'a bad entry never replaces a good one');
++console.log('console shipped: all checks passed');