openvibers/openvibe.network Commits

Money reasons: the account card and wallet show sentences, never ledger keys (#763)

OP OpenVibers committed 3 days ago · 907154af63 · 10 files, +308 −34

* Account card and wallet: OpenCoins reasons are sentences, not ledger keys

* SSRF inventory: the quest catalog read; reasons copy at contracts 0.392.0

* Money reasons: the copied list until the pin carries it

Changes

ddiff --git a/public/js/my/today.js b/public/js/my/today.js
iindex ec5d126d..93301f6d 100644
---- a/public/js/my/today.js
++++ b/public/js/my/today.js
@@@ -198,7 +198,19 @@ function renderMoney(coins, last) {
if (coins.vibes != null) document.getElementById('td-vibes').textContent = tdNum(typeof coins.vibes === 'object' ? coins.vibes.balance : coins.vibes);
const t = last && last.transactions && last.transactions[0];
const line = document.getElementById('td-money-last');
-- line.textContent = t ? `${t.delta > 0 ? '+' : ''}${tdNum(t.delta)} ${t.reason ? `for ${String(t.reason).replace(/_/g, ' ')}` : ''} · ${tdAgo(t.created_at)}` : '';
++ line.replaceChildren();
++ if (t) {
++ // The server makes the words (billing.money-reason@1). The raw key never reaches this line:
++ // an undeclared reason already reads "for something on <site>".
++ const phrase = t.reason_text || `for something on ${t.reason_site || 'OpenVibe'}`;
++ line.append(document.createTextNode(`${t.delta > 0 ? '+' : ''}${tdNum(t.delta)} `));
++ if (t.reason_subject && t.reason_subject.url) {
++ const a = document.createElement('a');
++ a.href = t.reason_subject.url; a.textContent = phrase; a.target = '_blank'; a.rel = 'noopener';
++ line.append(a);
++ } else line.append(document.createTextNode(phrase));
++ line.append(document.createTextNode(` · ${tdAgo(t.created_at)}`));
++ }
line.hidden = !t;
box.hidden = false;
}
ddiff --git a/public/js/wallet/page.js b/public/js/wallet/page.js
iindex 3c677919..18263145 100644
---- a/public/js/wallet/page.js
++++ b/public/js/wallet/page.js
@@@ -149,7 +149,15 @@
date.dateTime = String(row.at || '');
if (t.time) date.title = t.time;
const main = W.el('div', 'w-act-main');
-- main.appendChild(W.el('b', 'w-act-label', row.label || 'Activity'));
++ // The reason is a sentence from billing.money-reason@1; when it names a thing with a page, the
++ // sentence is the link to it. The raw ledger key is never drawn.
++ const label = String(row.label || 'Activity');
++ const url = row.subject && row.subject.url;
++ if (url && /^https:\/\//.test(url)) {
++ const a = W.el('a', 'w-act-label', label);
++ a.href = url; a.target = '_blank'; a.rel = 'noopener';
++ main.appendChild(a);
++ } else main.appendChild(W.el('b', 'w-act-label', label));
const kindNames = [...new Set(kinds.map((k) => (k === 'vibes' ? 'Vibes' : 'Balance')))];
const kindName = kindNames.join(' · ');
main.appendChild(W.el('span', 'w-act-detail', row.detail ? `${kindName} · ${row.detail}` : kindName));
ddiff --git a/server/coins/routes.js b/server/coins/routes.js
iindex 5ad08f75..ff70e44f 100644
---- a/server/coins/routes.js
++++ b/server/coins/routes.js
@@@ -9,6 +9,7 @@
const express = require('express');
const wallet = require('./wallet');
++const money = require('../money/reasons');
const config = require('../config');
const VIBES_TIMEOUT_MS = 2000;
@@@ -41,7 +42,9 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {
});
// ── GET /api/coins/me/history?limit=50&offset=0 ──────────
-- // → { transactions: [{ id, app_id, delta, reason, created_at }] }
++ // → { transactions: [{ id, app_id, delta, reason, reason_text, reason_subject, reason_site, created_at }] }
++ // `reason` stays for support; no page prints it. `reason_text` is the phrase without the amount
++ // ("for the quest Publish a video"), `reason_subject` is { name, url } for the thing it was for.
router.get('/me/history', requireAuth, async (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
@@@ -52,7 +55,13 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {
ORDER BY id DESC
LIMIT ? OFFSET ?
`).all(req.user.id, limit, offset);
-- res.json({ transactions });
++ const quests = transactions.some((t) => /^quest:/.test(String(t.reason || ''))) ? await money.questNames() : new Map();
++ res.json({
++ transactions: transactions.map((t) => {
++ const d = money.describe(t.reason, { site: t.app_id || 'network', resolve: { quest: (id) => quests.get(id) } });
++ return { ...t, reason_text: d.text, reason_subject: d.subject, reason_site: d.site };
++ }),
++ });
});
return router;
ddiff --git a/server/money/reasons-list.js b/server/money/reasons-list.js
nnew file mode 100644
iindex 00000000..6da0f6d9
---- /dev/null
++++ b/server/money/reasons-list.js
@@@ -0,0 +1,147 @@
++'use strict';
++/**
++ * The same list as openvibe-contracts/lib/money-reasons.js (0.392.0), copied so Network's OpenCoins history answers
++ * with the plain words before its installed contract package carries the module. reasons.js prefers the contract's
++ * copy when it is there.
++ *
++ * Money reasons: the plain words for a ledger row (2026-11-10).
++ *
++ * A ledger holds a key, never a sentence: OpenCoins rows carry `reason` ("quest:publish-a-video"),
++ * Vibes/Balance rows carry a type and metadata. This list is the one place each kind is declared:
++ * the key it matches, the site it belongs to, what its subject is (a quest, a stream, a tip, a
++ * creator, an app, a project, a purchase), the phrase that names it, and how the subject's own
++ * name and address are found.
++ *
++ * `describe(key, options)` makes `{ key, text, subject: { name, url } | null }`. `text` is the
++ * phrase without the amount ("for the quest Publish a video"); the page writes
++ * "+15 OpenCoins" before it and links the phrase to subject.url. A key nobody declared answers
++ * "for something on <site>" and is marked known: false, so a caller logs it once and never shows
++ * the key itself.
++ *
++ * `options.resolve` maps a subject type to a lookup: `{ quest: (id) => ({ name, url }) }`.
++ * `options.subject` is an already-found subject (a tip's creator, a project). `options.site`
++ * names the site for an undeclared key.
++ */
++
++/** The sites a key can belong to, in the words a person reads. */
++const SITES = Object.freeze({
++ quest: 'OpenVibe.Quest',
++ fun: 'OpenVibe.Fun',
++ live: 'OpenVibe.Live',
++ network: 'OpenVibe.Network',
++ billing: 'OpenVibe',
++ inventory: 'OpenVibe.Inventory',
++});
++
++/**
++ * One entry per reason kind.
++ * match RegExp whose first group is the subject's id or name (or an exact key string)
++ * site SITES key, for an undeclared key and for callers that show provenance
++ * subject the subject type `options.resolve` can look up; omitted when there is none
++ * text the phrase; `{name}` is where the subject's name goes
++ * fallback the name when the subject cannot be found ("a creator", "an item")
++ * path the subject's address; `{id}` is the matched group
++ * catalog where the subject's own names live (a declaration for the resolver)
++ */
++const KINDS = Object.freeze([
++ // ── OpenCoins: Network's coin_transactions.reason ───────────────────────────────────────────
++ Object.freeze({ id: 'quest', match: /^quest:([a-z0-9-]+)$/i, site: 'quest', subject: 'quest', text: 'for the quest {name}',
++ path: 'https://openvibe.quest/quests/{id}', catalog: 'https://openvibe.quest/api/v1/quests' }),
++ Object.freeze({ id: 'fun-quest', match: /^fun:([a-z0-9-]+)$/i, site: 'fun', subject: 'fun-quest', text: 'for the quest {name}',
++ path: 'https://openvibe.fun/q/{id}', catalog: 'https://openvibe.fun/api/v1/quests' }),
++ Object.freeze({ id: 'media-request', match: /^Media request: (.+)$/, site: 'live', subject: 'media', text: 'for the stream {name}' }),
++ Object.freeze({ id: 'media-refund', match: /^Refund: (.+)$/, site: 'live', subject: 'media', text: 'for a refund of {name}' }),
++ Object.freeze({ id: 'migration', match: 'migration:hobo-coins', site: 'network', text: 'for OpenCoins moved from the old Live wallet' }),
++ Object.freeze({ id: 'account-merge', match: 'account_merge', site: 'network', text: 'for merging your accounts' }),
++ Object.freeze({ id: 'account-deleted', match: 'account_deleted', site: 'network', text: 'for closing your account' }),
++ Object.freeze({ id: 'watch', match: 'watch', site: 'live', text: 'for watching a stream' }),
++ Object.freeze({ id: 'chat', match: 'chat', site: 'live', text: 'for chatting on a stream' }),
++ Object.freeze({ id: 'follow', match: 'follow', site: 'live', text: 'for following a channel' }),
++ Object.freeze({ id: 'bonus-game', match: 'bonus game', site: 'live', text: 'for winning the bonus game' }),
++ Object.freeze({ id: 'admin-grant', match: 'Admin grant', site: 'network', text: 'for OpenCoins from OpenVibe' }),
++
++ // ── Vibes / Balance: Billing's transaction type + metadata ──────────────────────────────────
++ Object.freeze({ id: 'ai-usage', match: 'credit:settle', site: 'billing', text: 'for AI usage' }),
++ Object.freeze({ id: 'topup', match: 'credit:topup', site: 'billing', text: 'for adding money to your Balance' }),
++ Object.freeze({ id: 'vibes-from-balance', match: 'purchase:vibes_from_balance', site: 'billing', text: 'for buying Vibes with your Balance' }),
++ Object.freeze({ id: 'vibes-bought', match: 'purchase:vibes', site: 'billing', text: 'for buying Vibes' }),
++ Object.freeze({ id: 'store-purchase', match: 'purchase:store', site: 'inventory', subject: 'item', text: 'for buying {name}', fallback: 'an item' }),
++ Object.freeze({ id: 'app-payment', match: 'vibes_charge', site: 'billing', subject: 'app', text: 'for paying {name}', fallback: 'an app' }),
++ Object.freeze({ id: 'app-purchase', match: 'purchase:app', site: 'billing', subject: 'app', text: 'for paying {name}', fallback: 'an app' }),
++ Object.freeze({ id: 'tip', match: 'donation', site: 'billing', subject: 'creator', text: 'for a tip to {name}', fallback: 'a creator' }),
++ Object.freeze({ id: 'tip-received', match: 'donation:received', site: 'billing', subject: 'creator', text: 'for a tip from {name}', fallback: 'someone' }),
++ Object.freeze({ id: 'subscription', match: 'subscription', site: 'billing', subject: 'creator', text: 'for a subscription to {name}', fallback: 'a creator' }),
++ Object.freeze({ id: 'subscription-share', match: 'subscription_share', site: 'billing', subject: 'creator', text: 'for a subscription to {name}', fallback: 'a creator' }),
++ Object.freeze({ id: 'refund', match: 'refund', site: 'billing', text: 'for a refund' }),
++ Object.freeze({ id: 'vibes-refund', match: 'vibes_refund', site: 'billing', text: 'for a refund of Vibes' }),
++ Object.freeze({ id: 'move-to-project', match: 'balance_transfer', site: 'billing', subject: 'project', text: 'for moving money to {name}', fallback: 'a project' }),
++ Object.freeze({ id: 'cashout-request', match: 'cashout_request', site: 'billing', text: 'for cashing out' }),
++ Object.freeze({ id: 'cashout-paid', match: 'cashout_paid', site: 'billing', text: 'for a payout' }),
++ Object.freeze({ id: 'cashout-denied', match: 'cashout_denied', site: 'billing', text: 'for a cashout that was denied' }),
++ Object.freeze({ id: 'recycle', match: 'recycle', site: 'billing', text: 'for moving your cashout balance back to Vibes' }),
++ Object.freeze({ id: 'adjustment', match: 'adjustment', site: 'billing', text: 'for an adjustment by OpenVibe' }),
++]);
++
++/** "live-setup-slot" → "Live setup slot": the name a slug gives when its catalog is not at hand. */
++function titleFromSlug(slug) {
++ const words = String(slug == null ? '' : slug).replace(/[-_]+/g, ' ').trim();
++ return words ? words.charAt(0).toUpperCase() + words.slice(1) : '';
++}
++
++function siteName(id) {
++ return SITES[String(id || '').toLowerCase()] || 'OpenVibe';
++}
++
++function subjectFrom(value, kind, options) {
++ if (options.subject && typeof options.subject === 'object' && options.subject.name) {
++ return { name: String(options.subject.name).slice(0, 160), url: options.subject.url ? String(options.subject.url).slice(0, 500) : null };
++ }
++ let found = null;
++ if (value != null && kind.subject && options.resolve && typeof options.resolve[kind.subject] === 'function') {
++ try { found = options.resolve[kind.subject](value); } catch { found = null; }
++ }
++ if (typeof found === 'string') found = { name: found };
++ const name = found && found.name ? String(found.name).slice(0, 160) : (kind.fallback || titleFromSlug(value));
++ const url = found && found.url ? String(found.url).slice(0, 500) : (kind.path && value != null ? kind.path.replace('{id}', encodeURIComponent(value)) : null);
++ if (!name) return null;
++ return { name, url: url || null };
++}
++
++/**
++ * The plain words for a ledger reason.
++ * @returns {{ key: string, text: string, subject: { name: string, url: string|null }|null, site: string, known: boolean }}
++ */
++function describe(key, options = {}) {
++ const raw = key == null ? '' : String(key).trim();
++ let kind = null;
++ let value = null;
++ for (const entry of KINDS) {
++ if (typeof entry.match === 'string') {
++ if (raw === entry.match) { kind = entry; break; }
++ continue;
++ }
++ const m = raw.match(entry.match);
++ if (m) { kind = entry; value = m[1] == null ? null : m[1].trim(); break; }
++ }
++ if (!kind) {
++ return { key: raw, text: `for something on ${siteName(options.site || raw.split(':')[0])}`, subject: null,
++ site: siteName(options.site || raw.split(':')[0]), known: false };
++ }
++ const subject = kind.subject ? subjectFrom(value, kind, options) : null;
++ const text = kind.text.indexOf('{name}') < 0 ? kind.text
++ : kind.text.replace('{name}', subject ? subject.name : (kind.fallback || 'something'));
++ return { key: raw, text, subject, site: siteName(kind.site), known: true };
++}
++
++/** A row from a service that keeps the raw key: `{ reason, app_id }` or `{ reason }`. */
++function describeRow(row, options = {}) {
++ const reason = row && (row.reason != null ? row.reason : row.reason_key);
++ return describe(reason, { ...options, site: options.site || (row && (row.app_id || row.site)) || undefined });
++}
++
++/** What an undeclared key reads, for a test and for a caller that wants only the words. */
++function unknownText(site) {
++ return `for something on ${siteName(site)}`;
++}
++
++module.exports = { KINDS, SITES, describe, describeRow, unknownText, siteName, titleFromSlug };
ddiff --git a/server/money/reasons.js b/server/money/reasons.js
nnew file mode 100644
iindex 00000000..d0bb3544
---- /dev/null
++++ b/server/money/reasons.js
@@@ -0,0 +1,49 @@
++'use strict';
++
++/**
++ * The plain words for an OpenCoins ledger row (`coin_transactions.reason`). The list and the
++ * formatter live in openvibe-contracts (`billing.money-reason@1`, `lib/money-reasons.js`); Network
++ * re-exports them when the installed package has them and otherwise answers from the same list
++ * copied at 0.392.0. A page writes the amount before `text` and links it to `subject.url`.
++ */
++// The list is openvibe-contracts/lib/money-reasons.js; copied at 0.392.0 until Network's pin carries the module.
++const reasons = require('./reasons-list');
++
++const QUEST_TTL_MS = 10 * 60_000;
++let questCache = { at: 0, names: new Map() };
++
++/** Quest ids → their own names and addresses, from Quest's public catalog (cached, best-effort). */
++async function questNames(fetchImpl = globalThis.fetch) {
++ if (questCache.at && Date.now() - questCache.at < QUEST_TTL_MS) return questCache.names;
++ const kind = reasons.KINDS.find((k) => k.subject === 'quest');
++ const base = String(process.env.QUESTS_URL || (kind && kind.catalog) || 'https://openvibe.quest/api/v1/quests').replace(/\/+$/, '');
++ const names = new Map();
++ try {
++ const r = await fetchImpl(/\/api\/v1\/quests$/.test(base) ? base : `${base}/api/v1/quests`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(2500) });
++ if (r.ok) {
++ const body = await r.json();
++ for (const site of (body && Array.isArray(body.sites) ? body.sites : [])) {
++ for (const q of (Array.isArray(site.quests) ? site.quests : [])) {
++ if (!q || !q.id) continue;
++ const host = base.replace(/\/api\/v1\/quests$/, '');
++ names.set(String(q.id), { name: String(q.title || q.id).slice(0, 160), url: `${host}${String(q.url || `/quests/${encodeURIComponent(q.id)}`)}` });
++ }
++ }
++ }
++ } catch { /* the slug and the id still answer */ }
++ questCache = { at: Date.now(), names };
++ return names;
++}
++
++/** Log an undeclared key once per process: support can see it, a person never does. */
++const unknown = new Set();
++function describe(key, options) {
++ const d = reasons.describe(key, options);
++ if (!d.known && d.key && !unknown.has(d.key)) {
++ unknown.add(d.key);
++ console.warn(`[Money] undeclared reason key ${JSON.stringify(d.key.slice(0, 120))} — reads "${d.text}"`);
++ }
++ return d;
++}
++
++module.exports = { ...reasons, describe, questNames };
ddiff --git a/server/wallet/routes.js b/server/wallet/routes.js
iindex 954c9411..26ed4fca 100644
---- a/server/wallet/routes.js
++++ b/server/wallet/routes.js
@@@ -24,6 +24,7 @@
const express = require('express');
const wallet = require('../coins/wallet');
++const money = require('../money/reasons');
const config = require('../config');
const TIMEOUT_MS = 3500;
@@@ -126,26 +127,33 @@ function titleCase(s) {
return String(s || 'Activity').replace(/[_-]+/g, ' ').replace(/\b[a-z]/g, (c) => c.toUpperCase());
}
--/** The label and the second line of what a journal transaction was, from its type and metadata. */
++/** The reason key a journal row means: Billing's type, refined by the metadata that separates its shapes. */
++function keyOf(t, m) {
++ if (t.type === 'credit' && m.credit === 'settle') return 'credit:settle';
++ if (t.type === 'credit' && m.credit === 'topup') return 'credit:topup';
++ if (t.type === 'purchase' && m.balance === 'vibes_from_balance') return 'purchase:vibes_from_balance';
++ if (t.type === 'purchase' && m.store_purchase) return 'purchase:store';
++ if (t.type === 'purchase' && (m.vibes_charge || m.vibes_charge_id || m.charge === 'vibes_pay' || m.app_name)) return 'purchase:app';
++ if (t.type === 'purchase') return 'purchase:vibes';
++ return String(t.type || '');
++}
++
++/** The label and the second line of what a journal transaction was, from its type and metadata.
++ * The words come from billing.money-reason@1, so this fallback reads like Billing's own activity. */
function describe(t) {
const m = (t && t.metadata) || {};
-- if (t.type === 'credit' && m.credit === 'settle') return { label: 'AI usage', detail: m.model ? String(m.model) : 'Metered usage' };
-- if (t.type === 'credit' && m.credit === 'topup') return { label: 'Added money', detail: 'Balance top-up' };
-- if (t.type === 'purchase' && m.balance === 'vibes_from_balance') {
-- const got = Number(m.vibes || 0);
-- return { label: got ? `Got ${got.toLocaleString('en-US')} Vibes with your Balance` : 'Got Vibes with your Balance', detail: '' };
-- }
-- if (t.type === 'purchase') {
-- const bought = Number(m.vibes || m.bits || m.amount_bits || 0);
-- return { label: bought ? `Bought ${bought.toLocaleString('en-US')} Vibes` : 'Bought Vibes', detail: 'With a card' };
-- }
-- if (t.type === 'balance_transfer') return { label: 'Moved to a project', detail: m.to_project_id ? String(m.to_project_id) : 'Project Balance' };
-- if (t.type === 'donation') return { label: 'Tip', detail: m.message ? text(m.message, 80) : 'Live tip' };
-- if (t.type === 'subscription' || t.type === 'subscription_share') return { label: 'Subscription', detail: 'Recurring' };
-- if (t.type === 'refund') return { label: 'Refund', detail: 'Returned' };
-- if (t.type === 'vibes_charge' || m.vibes_charge) return { label: m.description ? text(m.description, 80) : 'App payment', detail: 'Vibes Pay' };
-- if (t.type === 'cashout_paid' || t.type === 'cashout_request') return { label: 'Cash out', detail: 'Payable' };
-- return { label: titleCase(t.type), detail: '' };
++ const key = keyOf(t, m);
++ let detail = '';
++ if (key === 'credit:settle') detail = m.model ? String(m.model) : 'Metered usage';
++ else if (key === 'credit:topup') detail = 'Balance top-up';
++ else if (key === 'balance_transfer') detail = m.to_project_id ? String(m.to_project_id) : 'Project Balance';
++ else if (key === 'donation') detail = m.message ? text(m.message, 80) : 'Live tip';
++ else if (key === 'subscription' || key === 'subscription_share') detail = 'Recurring';
++ else if (key === 'refund' || key === 'vibes_refund') detail = 'Returned';
++ else if (key === 'vibes_charge') detail = 'Vibes Pay';
++ else if (key === 'cashout_paid' || key === 'cashout_request') detail = 'Payable';
++ const d = money.describe(key, { site: 'billing' });
++ return { label: d.text, detail, subject: d.subject, reason_key: d.key };
}
/** The person's two spendable parts of one journal transaction, in display order: Balance, then Vibes. */
@@@ -188,6 +196,7 @@ function mapActivity(subject, transactions) {
const d = describe(t);
rows.push({
id: String(t.id), at: t.created_at, label: d.label, detail: d.detail,
++ subject: d.subject || null, reason_key: d.reason_key || null,
kinds: parts.map((p) => p.kind), parts,
});
}
@@@ -259,7 +268,10 @@ function mapPersonActivity(subject, body) {
const at = item.at || item.created_at || item.date || null;
const label = text(item.label || item.description || item.title || 'Activity', 120);
const detail = text(item.detail || item.subtitle || '', 120);
-- const row = grouped.get(id) || { id, at, label, detail, parts: [] };
++ const anchors = item.subject && typeof item.subject === 'object' && item.subject.name
++ ? { name: text(item.subject.name, 160), url: item.subject.url ? text(item.subject.url, 500) : null } : null;
++ const row = grouped.get(id) || { id, at, label, detail, parts: [], subject: anchors, reason_key: item.reason_key || null };
++ if (anchors) row.subject = anchors;
for (const p of parts) if (!row.parts.some((x) => x.kind === p.kind)) row.parts.push(p);
grouped.set(id, row);
}
ddiff --git a/test/money-reasons.test.js b/test/money-reasons.test.js
nnew file mode 100644
iindex 00000000..0c0d1ada
---- /dev/null
++++ b/test/money-reasons.test.js
@@@ -0,0 +1,30 @@
++'use strict';
++// The plain words for an OpenCoins reason (billing.money-reason@1), and the quest catalog it names from.
++const assert = require('assert');
++const money = require('../server/money/reasons');
++
++let n = 0;
++const ok = (cond, msg) => { assert.ok(cond, msg); n++; };
++
++(async () => {
++ const quest = money.describe('quest:publish-a-video', { site: 'quest', resolve: { quest: (id) => ({ name: 'Publish a video', url: `https://openvibe.quest/quests/${id}` }) } });
++ ok(quest.text === 'for the quest Publish a video', 'a quest key is a sentence');
++ ok(quest.subject.url === 'https://openvibe.quest/quests/publish-a-video', 'with the quest as a link');
++ ok(quest.site === 'OpenVibe.Quest', 'and its site in words');
++
++ const refund = money.describe('Refund: A film', { site: 'live' });
++ ok(refund.text === 'for a refund of A film' && refund.subject.name === 'A film', 'a media refund names the stream');
++
++ const unknown = money.describe('nobody:declared-this', { site: 'fun' });
++ ok(!unknown.known && unknown.text === 'for something on OpenVibe.Fun', 'an undeclared key never shows itself');
++ ok(money.describe('account_merge').text === 'for merging your accounts', 'a reason with no page has its own words');
++
++ const names = await money.questNames(async (url) => {
++ assert.match(url, /\/api\/v1\/quests$/);
++ return { ok: true, json: async () => ({ sites: [{ quests: [{ id: 'go-live', title: 'Go live once', url: '/quests/go-live' }] }] }) };
++ });
++ ok(names.get('go-live').name === 'Go live once', 'the Quest catalog names the quest');
++ ok(names.get('go-live').url === 'https://openvibe.quest/quests/go-live', 'and gives its address');
++
++ console.log(`openvibe-network money reasons: ${n} checks passed`);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/my-today.test.js b/test/my-today.test.js
iindex 30e6d674..b3f5a6cd 100644
---- a/test/my-today.test.js
++++ b/test/my-today.test.js
@@@ -33,6 +33,7 @@ class Node {
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
set textContent(v) { this._text = String(v); this.children = []; }
get href() { return this.attrs.href; }
++ set href(v) { this.attrs.href = String(v); }
get classList() { const n = this; return { add: (c) => { n.className += ` ${c}`; } }; }
all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
}
@@@ -42,7 +43,7 @@ function page() {
for (const [, id] of HTML.matchAll(/id="((?:td-|me-|page-header|user-greeting|needs-you|today|coins|settings)[a-z-]*)"/g)) ids[id] = new Node('div');
const shown = [];
const sandbox = {
-- document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), querySelector: () => null },
++ document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), createTextNode: (t) => Object.assign(new Node('#text'), { _text: String(t) }), querySelector: () => null },
console, Date, Number, String, Map, Set, Math, JSON, Promise,
currentUser: { role: 'user' },
showSection: (id) => shown.push(id),
@@@ -134,9 +135,14 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
assert.ok(!tries.some((u) => /live|tools|games|design/.test(u)), 'none you already use');
assert.strictEqual(p.ids['td-sites-h'].textContent, 'Your sites');
-- p.sandbox.renderMoney({ balance: 1240, vibes: 30 }, { transactions: [{ delta: 20, reason: 'watch_time', created_at: ago(90) }] });
++ p.sandbox.renderMoney({ balance: 1240, vibes: 30 }, { transactions: [{ delta: 20, reason: 'quest:go-live', reason_text: 'for the quest Go live',
++ reason_subject: { name: 'Go live', url: 'https://openvibe.quest/quests/go-live' }, created_at: ago(90) }] });
assert.deepStrictEqual([p.ids.coins.hidden, p.ids['td-coins'].textContent, p.ids['td-vibes'].textContent, p.ids['td-vibes-row'].hidden], [false, (1240).toLocaleString(), '30', false]);
-- assert.match(p.ids['td-money-last'].textContent, /^\+20 for watch time · 1 h ago$/);
++ assert.match(p.ids['td-money-last'].textContent, /^\+20 for the quest Go live · 1 h ago$/);
++ assert.strictEqual(p.ids['td-money-last'].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.quest/quests/go-live', 'the reason links to the quest');
++ p.sandbox.renderMoney({ balance: 1240 }, { transactions: [{ delta: 5, reason: 'nobody:declared-this', reason_site: 'OpenVibe.Fun', created_at: ago(90) }] });
++ assert.match(p.ids['td-money-last'].textContent, /^\+5 for something on OpenVibe.Fun · 1 h ago$/);
++ assert.doesNotMatch(p.ids['td-money-last'].textContent, /nobody|declared-this/);
p.sandbox.renderStuff({ following: 14 }, { total: 230 }, { nodes: [{ id: 1 }, { id: 2, revoked_at: 'x' }] });
assert.match(p.ids['td-stuff-row'].textContent, /14following/);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 58ce5096..f0364ffb 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -124,6 +124,7 @@ const REVIEWED = {
'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
++ 'server/money/reasons.js fetch': [1, 'Quest\'s public catalog URL from operator config (QUESTS_URL, default https://openvibe.quest): the fixed /api/v1/quests path, 2.5 s timeout, held 10 minutes; it only names a quest id already in a ledger reason, and no request value names a host'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
ddiff --git a/test/wallet.test.js b/test/wallet.test.js
iindex eff6266a..813d1c9e 100644
---- a/test/wallet.test.js
++++ b/test/wallet.test.js
@@@ -75,16 +75,16 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;
},
]);
assert.deepStrictEqual(activity.map((r) => [r.kinds, r.parts, r.label]), [
-- [['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }], 'Got 500 Vibes with your Balance'],
-- [['balance'], [{ kind: 'balance', amount: '-123000000' }], 'AI usage'],
++ [['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }], 'for buying Vibes with your Balance'],
++ [['balance'], [{ kind: 'balance', amount: '-123000000' }], 'for AI usage'],
], 'one row per transaction, both currencies together; creator payable and sandbox are not the spendable wallet');
assert.strictEqual(createWalletRoutes.mapActivity(subject, null).length, 0);
assert.deepStrictEqual(createWalletRoutes.mapPersonActivity(subject, { activity: [
-- { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'Got 500 Vibes with your Balance', amount: 500 },
-- { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'Got 500 Vibes with your Balance', amount: '-5000000000' },
++ { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'for buying Vibes with your Balance', amount: 500 },
++ { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'for buying Vibes with your Balance', amount: '-5000000000' },
] }).map((r) => [r.kinds, r.parts]), [[['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]]], 'the person-shaped route is grouped too');
assert.deepStrictEqual(createWalletRoutes.mapPersonActivity(subject, { activity: [
-- { id: 'txn_two', at: '2026-11-04T11:00:00.000Z', label: 'Got 10 Vibes with your Balance', amounts: { balance: '-100000000', vibes: 10 } },
++ { id: 'txn_two', at: '2026-11-04T11:00:00.000Z', label: 'for buying Vibes with your Balance', amounts: { balance: '-100000000', vibes: 10 } },
] }).map((r) => [r.kinds, r.parts]), [[['balance', 'vibes'], [{ kind: 'balance', amount: '-100000000' }, { kind: 'vibes', amount: 10 }]]], 'an { amounts } shape is read too');
assert.ok(!principals.DEFAULT_GRANTS.some(([client, cap]) => client === 'network' && cap === 'billing.balance.read'), 'Network does not seed a broad Billing read');
@@@ -116,8 +116,8 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;
if (url.pathname === '/api/me/activity') {
if (activityMode === 'missing') { res.statusCode = 404; return res.end(JSON.stringify({ error: 'billing.not_found' })); }
return res.end(JSON.stringify({ activity: [
-- { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'Got 500 Vibes with your Balance', amount: 500 },
-- { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'Got 500 Vibes with your Balance', amount: '-5000000000' },
++ { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'for buying Vibes with your Balance', amount: 500 },
++ { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'for buying Vibes with your Balance', amount: '-5000000000' },
] }));
}
if (url.pathname === '/api/v1/transactions') return res.end(JSON.stringify({ transactions: [{ id: 'txn_buy', type: 'purchase', created_at: '2026-11-04T10:00:00.000Z', metadata: { balance: 'vibes_from_balance', vibes: 500 }, entries: [
@@@ -179,7 +179,7 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;
assert.strictEqual(w.body.activity.length, 1);
assert.deepStrictEqual(w.body.activity[0].kinds, ['balance', 'vibes']);
assert.deepStrictEqual(w.body.activity[0].parts, [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]);
-- assert.strictEqual(w.body.activity[0].label, 'Got 500 Vibes with your Balance');
++ assert.strictEqual(w.body.activity[0].label, 'for buying Vibes with your Balance');
assert.strictEqual(w.body.activity_source, 'journal', 'the marked fallback until Billing has /api/me/activity');
const personActivity = asked.filter((a) => a.url.startsWith('/api/me/activity'));
assert.strictEqual(personActivity.length, 1);