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)
Changes
@@@ -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);@@@ -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 = 'httpreturn 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 = 'httpprivate: !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 = 'httpshowcase: 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 = 'httpif (!found) return next(); // the shared 404 pageif (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>@@@ -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 <Starter>[\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();@@@ -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'],
@@ -680,7 +680,7 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)
@@ -680,7 +680,7 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)
app.locals.avatarService = avatarService;
app.locals.avatarService = avatarService;
// Public profiles (plan T21 step 3): openvibe.network/@<username> and GET /api/v1/profiles/:username (CORS-open,
// 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).
// 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.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);
app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles.pageHandler);
app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles.pageHandler);
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
// Items come from OpenVibe.Inventory's public reads (GET /api/v1/kinds, /people/:subject/items and /equipped) with the
// 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
// 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.
// 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
// 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
// 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');
@@ -21,6 +22,7 @@ const usernames = require('../identity/usernames');
const esc = seo.esc;
const esc = seo.esc;
const SITE = 'https://openvibe.network';
const SITE = 'https://openvibe.network';
const INVENTORY_SITE = 'https://inventory.openvibe.network';
const INVENTORY_SITE = 'https://inventory.openvibe.network';
+const QUEST_SITE = 'https://openvibe.quest';
const AUDIENCE = 'openvibe.inventory';
const AUDIENCE = 'openvibe.inventory';
const ITEMS_TTL_MS = 60_000;
const ITEMS_TTL_MS = 60_000;
const KINDS_TTL_MS = 10 * 60_000;
const KINDS_TTL_MS = 10 * 60_000;
@@ -45,9 +47,10 @@ function monthOf(ts) {
@@ -45,9 +47,10 @@ function monthOf(ts) {
return m && MONTHS[Number(m[2]) - 1] ? `${MONTHS[Number(m[2]) - 1]} ${m[1]}` : null;
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 base = String(inventoryUrl).replace(/\/+$/, '');
const itemsCache = new Map(); // subject → { at, value }
const itemsCache = new Map(); // subject → { at, value }
+ const badgesCache = new Map(); // subject → { at, value }
let kindsHit = null;
let kindsHit = null;
let lastWarn = '';
let lastWarn = '';
@@ -69,6 +72,41 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -69,6 +72,41 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
return value;
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. */
/** A person's items and what they wear, or { unavailable: true } while Inventory cannot answer. */
async function itemsOf(subject) {
async function itemsOf(subject) {
if (!SUBJECT_RE.test(String(subject || ''))) return { items: [], showcase: [], total: 0, more: false };
if (!SUBJECT_RE.test(String(subject || ''))) return { items: [], showcase: [], total: 0, more: false };
@@ -134,7 +172,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -134,7 +172,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
private: !Number(u.profile_public),
private: !Number(u.profile_public),
};
};
if (out.private) return out;
if (out.private) return out;
- const inv = await itemsOf(u.subject_id);
+ const inv = await wearsOf(u.subject_id);
const counts = new Map();
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 });
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, {
return Object.assign(out, {
@@ -144,6 +182,8 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -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 })),
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 },
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,
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
@@ -154,7 +194,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
if (!found) return next(); // the shared 404 page
if (!found) return next(); // the shared 404 page
if (found.moved) return res.redirect(301, `/@${encodeURIComponent(found.moved)}`);
if (found.moved) return res.redirect(301, `/@${encodeURIComponent(found.moved)}`);
const u = found.user;
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('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
res.send(renderPage(u, inv));
res.send(renderPage(u, inv));
@@ -172,7 +212,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -172,7 +212,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
} catch (err) { next(err); }
} catch (err) { next(err); }
});
});
- return { pageHandler, api, find, profileOf, itemsOf };
+ return { pageHandler, api, find, profileOf, itemsOf, badgesOf };
}
}
// ── The page ───────────────────────────────────────────────────
// ── The page ───────────────────────────────────────────────────
@@ -194,6 +234,13 @@ function showcaseHtml(inv, name) {
@@ -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>`;
<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) {
function itemsHtml(inv, u) {
const name = esc(u.display_name || u.username);
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>`;
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) {
@@ -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(', ')}` : ''}.`);
: (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 ? [
const stats = isPublic ? [
since ? `<li><span class="pf-stat-k">Member since</span><span class="pf-stat-v">${esc(since)}</span></li>` : '',
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.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>` : '',
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('') : '';
].join('') : '';
@@ -303,6 +351,11 @@ a{color:var(--accent)}
@@ -303,6 +351,11 @@ a{color:var(--accent)}
.pf-art img{width:100%;height:100%;object-fit:cover}
.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))}
.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)}
.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-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}
.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))}}
@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
@@ -321,7 +374,7 @@ ${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links
${bio ? `<p class="pf-bio">${esc(bio)}</p>` : ''}
${bio ? `<p class="pf-bio">${esc(bio)}</p>` : ''}
${isPublic ? `<ul class="pf-stats">${stats}</ul>` : '<p class="pf-private">This profile is private.</p>'}
${isPublic ? `<ul class="pf-stats">${stats}</ul>` : '<p class="pf-private">This profile is private.</p>'}
</section>
</section>
-${isPublic ? showcaseHtml(inv, name) + itemsHtml(inv, u) : ''}
+${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + itemsHtml(inv, u) : ''}
</main>
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
@@ -61,12 +61,13 @@ const INSTANCES = [
@@ -61,12 +61,13 @@ const INSTANCES = [
if (u.pathname === '/api/v1/kinds') return json(200, { kinds: KINDS });
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}/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/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' });
return json(404, { code: 'inventory.unknown_subject' });
});
});
await new Promise((r) => inv.listen(0, '127.0.0.1', r));
await new Promise((r) => inv.listen(0, '127.0.0.1', r));
let clock = Date.now();
let clock = Date.now();
const selfToken = (aud, caps) => `tok:${aud}:${caps.join(',')}`;
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();
const app = express();
app.get('/@:username', profiles.pageHandler);
app.get('/@:username', profiles.pageHandler);
@@ -93,7 +94,10 @@ const INSTANCES = [
@@ -93,7 +94,10 @@ const INSTANCES = [
assert.ok(r.text.includes('--pf-color:#ff0066'));
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(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(!/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 <Starter>[\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.
// Cached for a minute: a second view reads nothing.
const n = calls.length;
const n = calls.length;
@@ -108,6 +112,7 @@ const INSTANCES = [
@@ -108,6 +112,7 @@ const INSTANCES = [
assert.strictEqual(p.avatar_url, 'https://openvibe.network/avatar/Ana?s=160');
assert.strictEqual(p.avatar_url, 'https://openvibe.network/avatar/Ana?s=160');
assert.strictEqual(p.inventory_url, `https://inventory.openvibe.network/u/${A}`);
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.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.
// A hidden profile: name and picture, nothing else, not indexed.
r = await get('/@hidden');
r = await get('/@hidden');
@@ -137,6 +142,7 @@ const INSTANCES = [
@@ -137,6 +142,7 @@ const INSTANCES = [
assert.ok(r.text.includes('The items could not be loaded just now'));
assert.ok(r.text.includes('The items could not be loaded just now'));
r = await get('/api/v1/profiles/Ana');
r = await get('/api/v1/profiles/Ana');
assert.strictEqual(JSON.parse(r.text).profile.items.unavailable, true);
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');
console.log('public profiles: all checks passed');
} finally {
} finally {
srv.close();
srv.close();
@@ -123,7 +123,7 @@ const REVIEWED = {
@@ -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/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/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/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/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/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'],
'server/registry/deploy-drift.js fetch': [1, 'fixed host api.github.com'],