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
Changes
@@@ -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) {@@@ -55,6 +55,7 @@ fieldset{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margilegend{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 } };nnew file mode 100644@@@ -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 <script>alert(1)</script> fix') && !page.includes('<script>alert'), 'escaped');++assert.ok(page.includes('href="https://openvibe.network/updates?site=billing"'));++assert.ok(!/<script/i.test(page.replace('<script>', '')), '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');
@@ -42,7 +42,7 @@ const { createSessions, sameString, random } = require('./session');
@@ -42,7 +42,7 @@ const { createSessions, sameString, random } = require('./session');
const sso = require('./sso');
const sso = require('./sso');
const audit = require('./audit');
const audit = require('./audit');
const q = require('./queries');
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. */
/** Server-side mapping: what a staff session holds, in the API's own capability ids. */
const STAFF_CAPABILITIES = Object.freeze([CAP.cashoutManage, CAP.admin]);
const STAFF_CAPABILITIES = Object.freeze([CAP.cashoutManage, CAP.admin]);
@@ -73,6 +73,21 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
@@ -73,6 +73,21 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
const r = express.Router();
const r = express.Router();
const baseOrigin = (() => { try { return new URL(config.baseUrl).origin; } catch { return null; } })();
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 secret = cc.sessionSecret;
let unavailable = null;
let unavailable = null;
if (!secret || secret.length < 32) {
if (!secret || secret.length < 32) {
@@ -55,6 +55,7 @@ fieldset{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margi
@@ -55,6 +55,7 @@ fieldset{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margi
legend{padding:0 6px;font-weight:600}
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}
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}
.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 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'`;
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 = [
@@ -81,6 +82,25 @@ const NAV = [
['/import-holds', 'Import holds', 'holds'], ['/reconciliation', 'Reconciliation', 'reconciliation'], ['/freeze', 'Freeze', 'freeze'], ['/audit', 'Audit log', 'audit'],
['/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 }) {
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">
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>
<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
@@ -93,7 +113,7 @@ ${frozen ? html`<div class="banner frozen"><strong>The economy is frozen.</stron
${notice ? html`<div class="banner done">${notice}</div>` : ''}
${notice ? html`<div class="banner done">${notice}</div>` : ''}
${error ? html`<div class="banner error"><strong>Refused:</strong> ${error}</div>` : ''}
${error ? html`<div class="banner error"><strong>Refused:</strong> ${error}</div>` : ''}
${body}
${body}
-</main></body></html>`)}`;
+</main>${shippedLine()}</body></html>`)}`;
}
}
// ── Pages ────────────────────────────────────────────────────
// ── Pages ────────────────────────────────────────────────────
@@ -278,4 +298,4 @@ ${rows.length ? auditTable(rows) : html`<div class="card muted">Empty.</div>`}`,
@@ -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 } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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 <script>alert(1)</script> fix') && !page.includes('<script>alert'), 'escaped');
+assert.ok(page.includes('href="https://openvibe.network/updates?site=billing"'));
+assert.ok(!/<script/i.test(page.replace('<script>', '')), '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');