openvibers/openvibe.network Commits

Network: profiles show the person's OpenVibe.Quest badges, newest first, read with the Network's own token for openvibe.quest (#101)

OP OpenVibers committed 9 Oct 2026 · a2d57853bc · 4 files, +68 −9

Changes

ddiff --git a/server/index.js b/server/index.js
iindex 8601f15d..a795d010 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -680,7 +680,7 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)
app.locals.avatarService = avatarService;
// Public profiles (plan T21 step 3): openvibe.network/@<username> and GET /api/v1/profiles/:username (CORS-open,
// server/public-cors.js), with the items from OpenVibe.Inventory (server/profile/public.js).
--const publicProfiles = require('./profile/public').createPublicProfiles({ db, selfToken, inventoryUrl: process.env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030' });
++const publicProfiles = require('./profile/public').createPublicProfiles({ db, selfToken, inventoryUrl: process.env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030', questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980' });
app.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);
app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles.pageHandler);
ddiff --git a/server/profile/public.js b/server/profile/public.js
iindex 4902832a..1345ceba 100644
---- a/server/profile/public.js
++++ b/server/profile/public.js
@@@ -8,6 +8,7 @@
// Items come from OpenVibe.Inventory's public reads (GET /api/v1/kinds, /people/:subject/items and /equipped) with the
// Network's own service token (svc:network, audience openvibe.inventory), kept 60 s per person, so a busy profile costs
// Inventory one read a minute. Inventory down: the profile still answers, with its items marked unavailable.
++// Badges come the same way from OpenVibe.Quest (GET /api/v1/profiles/:subject/badges, audience openvibe.quest).
//
// Who has one: a real account (not anonymous, banned, merged into another or deleted); anyone else is a 404. An old
// username answers 301 to the current one. A person can hide theirs (users.profile_public = 0, the account hub's Edit
@@@ -21,6 +22,7 @@ const usernames = require('../identity/usernames');
const esc = seo.esc;
const SITE = 'https://openvibe.network';
const INVENTORY_SITE = 'https://inventory.openvibe.network';
++const QUEST_SITE = 'https://openvibe.quest';
const AUDIENCE = 'openvibe.inventory';
const ITEMS_TTL_MS = 60_000;
const KINDS_TTL_MS = 10 * 60_000;
@@@ -45,9 +47,10 @@ function monthOf(ts) {
return m && MONTHS[Number(m[2]) - 1] ? `${MONTHS[Number(m[2]) - 1]} ${m[1]}` : null;
}
--function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http://127.0.0.1:5030', fetchImpl = (...a) => fetch(...a), now = () => Date.now(), log = console } = {}) {
++function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http://127.0.0.1:5030', questUrl = 'http://127.0.0.1:4980', fetchImpl = (...a) => fetch(...a), now = () => Date.now(), log = console } = {}) {
const base = String(inventoryUrl).replace(/\/+$/, '');
const itemsCache = new Map(); // subject → { at, value }
++ const badgesCache = new Map(); // subject → { at, value }
let kindsHit = null;
let lastWarn = '';
@@@ -69,6 +72,41 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
return value;
}
++ /** A person's Quest badges, newest first: [{ id, name, awarded_at }], or { unavailable: true } while Quest cannot answer. */
++ async function badgesOf(subject) {
++ if (!SUBJECT_RE.test(String(subject || ''))) return { badges: [] };
++ const hit = badgesCache.get(subject);
++ if (hit && now() - hit.at < ITEMS_TTL_MS) return hit.value;
++ let value;
++ try {
++ const token = selfToken('openvibe.quest', ['quest.badge.read']);
++ const r = await fetchImpl(`${String(questUrl).replace(/\/+$/, '')}/api/v1/profiles/${subject}/badges`, {
++ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ if (!r.ok) throw new Error(`answered ${r.status}`);
++ const body = await r.json();
++ const badges = (Array.isArray(body.badges) ? body.badges : [])
++ .filter((b) => b && typeof b.id === 'string' && typeof b.name === 'string')
++ .map((b) => ({ id: b.id.slice(0, 64), name: b.name.slice(0, 60), awarded_at: typeof b.awarded_at === 'string' ? b.awarded_at : null }))
++ .sort((a, b) => String(b.awarded_at || '').localeCompare(String(a.awarded_at || '')));
++ value = { badges };
++ } catch (err) {
++ const m = `OpenVibe.Quest: ${err.message}`;
++ if (m !== lastWarn) { lastWarn = m; log.warn(`[Profiles] ${m}`); }
++ return { badges: [], unavailable: true };
++ }
++ badgesCache.set(subject, { at: now(), value });
++ if (badgesCache.size > 2000) badgesCache.delete(badgesCache.keys().next().value);
++ return value;
++ }
++
++ /** What a public profile shows from other services: its items (Inventory) and its badges (Quest), read together. */
++ async function wearsOf(subject) {
++ const [inv, quest] = await Promise.all([itemsOf(subject), badgesOf(subject)]);
++ return { ...inv, badges: quest.badges, badgesUnavailable: !!quest.unavailable };
++ }
++
/** A person's items and what they wear, or { unavailable: true } while Inventory cannot answer. */
async function itemsOf(subject) {
if (!SUBJECT_RE.test(String(subject || ''))) return { items: [], showcase: [], total: 0, more: false };
@@@ -134,7 +172,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
private: !Number(u.profile_public),
};
if (out.private) return out;
-- const inv = await itemsOf(u.subject_id);
++ const inv = await wearsOf(u.subject_id);
const counts = new Map();
for (const i of inv.items) counts.set(i.kind, { kind: i.kind, name: i.kind_name, count: ((counts.get(i.kind) || {}).count || 0) + 1 });
return Object.assign(out, {
@@@ -144,6 +182,8 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
showcase: inv.showcase.map((i) => ({ slot: i.slot, kind: i.kind, kind_name: i.kind_name, definition_id: i.definition_id, name: i.name, rarity: i.rarity, art: i.art, url: i.url })),
items: { count: inv.total, more: inv.more, by_kind: [...counts.values()], unavailable: !!inv.unavailable },
inventory_url: SUBJECT_RE.test(String(u.subject_id || '')) ? `${INVENTORY_SITE}/u/${u.subject_id}` : null,
++ badges: inv.badges,
++ ...(inv.badgesUnavailable ? { badges_unavailable: true } : {}),
});
}
@@@ -154,7 +194,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
if (!found) return next(); // the shared 404 page
if (found.moved) return res.redirect(301, `/@${encodeURIComponent(found.moved)}`);
const u = found.user;
-- const inv = Number(u.profile_public) ? await itemsOf(u.subject_id) : null;
++ const inv = Number(u.profile_public) ? await wearsOf(u.subject_id) : null;
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
res.send(renderPage(u, inv));
@@@ -172,7 +212,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
} catch (err) { next(err); }
});
-- return { pageHandler, api, find, profileOf, itemsOf };
++ return { pageHandler, api, find, profileOf, itemsOf, badgesOf };
}
// ── The page ───────────────────────────────────────────────────
@@@ -194,6 +234,13 @@ function showcaseHtml(inv, name) {
<p class="pf-note">What ${esc(name)} shows in chat, on stream overlays and here.</p></section>`;
}
++/** Quest badges: one chip each, newest first, linking to the quests that award them. */
++function badgesHtml(inv) {
++ if (!inv.badges || !inv.badges.length) return '';
++ return `<section class="pf-section" aria-labelledby="pf-badges"><h2 id="pf-badges">Badges <span class="pf-count">${inv.badges.length}</span></h2>
++<ul class="pf-badges">${inv.badges.map((b) => `<li><a class="pf-badge" href="${QUEST_SITE}/" title="${esc(b.awarded_at ? `Earned ${monthOf(b.awarded_at) || ''} on OpenVibe.Quest` : 'Earned on OpenVibe.Quest')}"><span class="pf-badge-ic" aria-hidden="true">🏅</span><span>${esc(b.name)}</span>${b.awarded_at && monthOf(b.awarded_at) ? `<small>${esc(monthOf(b.awarded_at))}</small>` : ''}</a></li>`).join('')}</ul></section>`;
++}
++
function itemsHtml(inv, u) {
const name = esc(u.display_name || u.username);
if (inv.unavailable) return `<section class="pf-section" aria-labelledby="pf-items"><h2 id="pf-items">Items</h2><p class="pf-empty">The items could not be loaded just now. They are safe on <a href="${INVENTORY_SITE}/">OpenVibe.Inventory</a>; try again in a minute.</p></section>`;
@@@ -233,6 +280,7 @@ function renderPage(u, inv) {
: (bio ? bio.slice(0, 155) : `${name}'s profile on OpenVibe${inv.total ? `: ${plural(inv.total, 'item', 'items')}` : ''}${inv.showcase.length ? `, wearing ${inv.showcase.slice(0, 3).map((i) => i.name).join(', ')}` : ''}.`);
const stats = isPublic ? [
since ? `<li><span class="pf-stat-k">Member since</span><span class="pf-stat-v">${esc(since)}</span></li>` : '',
++ inv.badges && inv.badges.length ? `<li><span class="pf-stat-k">Badges</span><span class="pf-stat-v">${inv.badges.length}</span></li>` : '',
inv.unavailable ? '' : `<li><span class="pf-stat-k">Items</span><span class="pf-stat-v">${inv.more ? `${inv.total}+` : inv.total}</span></li>`,
inv.items.length ? `<li><span class="pf-stat-k">Rarest</span><span class="pf-stat-v">${esc(RARITY_LABEL[inv.items.reduce((best, i) => (rarityRank(i.rarity) > rarityRank(best) ? i.rarity : best), 'common')])}</span></li>` : '',
].join('') : '';
@@@ -303,6 +351,11 @@ a{color:var(--accent)}
.pf-art img{width:100%;height:100%;object-fit:cover}
.pf-rarity{font-size:.68rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:color-mix(in srgb,var(--rc) 70%,var(--text-primary))}
.r-common{--rc:var(--r-common)}.r-uncommon{--rc:var(--r-uncommon)}.r-rare{--rc:var(--r-rare)}.r-epic{--rc:var(--r-epic)}.r-legendary{--rc:var(--r-legendary)}
++.pf-badges{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}
++.pf-badge{display:inline-flex;align-items:center;gap:8px;padding:7px 14px 7px 10px;border-radius:999px;border:1px solid color-mix(in srgb,var(--r-legendary) 40%,var(--border));background:color-mix(in srgb,var(--r-legendary) 10%,var(--bg-secondary));color:var(--text-primary);text-decoration:none;font-weight:600;font-size:.88rem}
++.pf-badge:hover,.pf-badge:focus-visible{border-color:var(--r-legendary)}
++.pf-badge small{font-weight:500;color:var(--text-muted);font-size:.75rem}
++.pf-badge-ic{font-size:1.05rem}
.pf-empty{color:var(--text-secondary);padding:18px;border:1px dashed var(--border);border-radius:12px;margin:0}
.pf-note{color:var(--text-secondary);font-size:.85rem;margin:12px 0 0}
@media (max-width:560px){.pf-head{padding:0 16px 16px;gap:14px}.pf-avatar{width:92px;height:92px}.pf-hat{font-size:32px;top:-18px}.pf-who{padding-top:0;min-width:100%}.pf-bio,.pf-stats,.pf-private{padding-left:16px;padding-right:16px}.pf-grid{grid-template-columns:repeat(auto-fill,minmax(112px,1fr))}}
@@@ -321,7 +374,7 @@ ${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links
${bio ? `<p class="pf-bio">${esc(bio)}</p>` : ''}
${isPublic ? `<ul class="pf-stats">${stats}</ul>` : '<p class="pf-private">This profile is private.</p>'}
</section>
--${isPublic ? showcaseHtml(inv, name) + itemsHtml(inv, u) : ''}
++${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + itemsHtml(inv, u) : ''}
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
<script src="/shared/navbar.js" defer></script>
ddiff --git a/test/public-profile.test.js b/test/public-profile.test.js
iindex ae63c94d..0f76ee00 100644
---- a/test/public-profile.test.js
++++ b/test/public-profile.test.js
@@@ -61,12 +61,13 @@ const INSTANCES = [
if (u.pathname === '/api/v1/kinds') return json(200, { kinds: KINDS });
if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: { 'live.hat:hat': { instance_id: INSTANCES[0].id, definition_id: INSTANCES[0].definition_id }, 'live.name_effect:name_effect': { instance_id: INSTANCES[1].id, definition_id: INSTANCES[1].definition_id, token: 'name-fx-rainbow' } } });
if (u.pathname === `/api/v1/people/${A}/items`) return json(200, { subject: A, instances: INSTANCES, definitions: DEFS, next_cursor: null });
++ if (u.pathname === `/api/v1/profiles/${A}/badges`) return json(200, { subject: A, badges: [{ id: 'first-follow', name: 'First Follow', awarded_at: '2026-10-02T10:00:00Z' }, { id: 'thread-starter', name: 'Thread <Starter>', awarded_at: '2026-10-05T10:00:00Z' }] });
return json(404, { code: 'inventory.unknown_subject' });
});
await new Promise((r) => inv.listen(0, '127.0.0.1', r));
let clock = Date.now();
const selfToken = (aud, caps) => `tok:${aud}:${caps.join(',')}`;
-- const profiles = createPublicProfiles({ db, selfToken, inventoryUrl: `http://127.0.0.1:${inv.address().port}`, now: () => clock, log: { warn() {} } });
++ const profiles = createPublicProfiles({ db, selfToken, inventoryUrl: `http://127.0.0.1:${inv.address().port}`, questUrl: `http://127.0.0.1:${inv.address().port}`, now: () => clock, log: { warn() {} } });
const app = express();
app.get('/@:username', profiles.pageHandler);
@@@ -93,7 +94,10 @@ const INSTANCES = [
assert.ok(r.text.includes('--pf-color:#ff0066'));
assert.ok(r.text.includes('https://inventory.openvibe.network/items/itd_01JZ00000000000000000001R1'), 'items link to their Inventory page');
assert.ok(!/name="robots" content="noindex"/.test(r.text));
-- assert.ok(calls.length > 0 && calls.every((c) => c.auth === 'Bearer tok:openvibe.inventory:inventory.item.read'), 'Network\'s own token for Inventory');
++ assert.ok(calls.filter((c) => !c.path.includes('/badges')).every((c) => c.auth === 'Bearer tok:openvibe.inventory:inventory.item.read'), 'Network\'s own token for Inventory');
++ assert.strictEqual(calls.find((c) => c.path.endsWith('/badges')).auth, 'Bearer tok:openvibe.quest:quest.badge.read', 'and for Quest');
++ assert.ok(r.text.includes('id="pf-badges"') && /Thread &lt;Starter&gt;[\s\S]*First Follow/.test(r.text), 'badges, newest first, escaped');
++ assert.ok(/<span class="pf-stat-k">Badges<\/span><span class="pf-stat-v">2<\/span>/.test(r.text));
// Cached for a minute: a second view reads nothing.
const n = calls.length;
@@@ -108,6 +112,7 @@ const INSTANCES = [
assert.strictEqual(p.avatar_url, 'https://openvibe.network/avatar/Ana?s=160');
assert.strictEqual(p.inventory_url, `https://inventory.openvibe.network/u/${A}`);
assert.deepStrictEqual(p.items.by_kind.map((k) => k.kind).sort(), ['live.hat', 'live.name_effect', 'live.particle']);
++ assert.deepStrictEqual(p.badges.map((b) => b.id), ['thread-starter', 'first-follow']);
// A hidden profile: name and picture, nothing else, not indexed.
r = await get('/@hidden');
@@@ -137,6 +142,7 @@ const INSTANCES = [
assert.ok(r.text.includes('The items could not be loaded just now'));
r = await get('/api/v1/profiles/Ana');
assert.strictEqual(JSON.parse(r.text).profile.items.unavailable, true);
++ assert.strictEqual(JSON.parse(r.text).profile.badges_unavailable, true, 'Quest down too: said, not hidden');
console.log('public profiles: all checks passed');
} finally {
srv.close();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex b492fdbc..a35beb82 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -123,7 +123,7 @@ const REVIEWED = {
'server/index.js fetch': [1, 'the admin streamer proxy: Live\'s internal URL from config, fixed path prefixes'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
-- 'server/profile/public.js fetch': [2, 'OpenVibe.Inventory at OV_INVENTORY_INTERNAL_URL (loopback 5030) only; the paths are fixed, the subject is a checked usr_ id'],
++ 'server/profile/public.js fetch': [3, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030) and OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) only; the paths are fixed, the subject is a checked usr_ id'],
'server/profile/avatar.js fetch': [3, 'Live and Media internal URLs from config; verifyImage only for https://openvibe.media/ addresses (normalizeAvatar, asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],
'server/registry/deploy-drift.js fetch': [1, 'fixed host api.github.com'],