openvibers/openvibe.network Commits
Profiles show your games and achievements (OpenVibe.Play) beside your items and badges, on the page and in the JSON every site reads: one Steam-style profile wherever you play (#333)
Changes
@@@ -657,7 +657,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', questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980' });++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', playUrl: process.env.OV_PLAY_INTERNAL_URL || 'http://127.0.0.1:5040' });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,7 +8,9 @@// 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).++// Badges come the same way from OpenVibe.Quest (GET /api/v1/profiles/:subject/badges, audience openvibe.quest), and++// achievements from OpenVibe.Play's public read (GET /api/v1/people/:subject/achievements): every game's unlocks, so a++// profile is the person's Steam-style page wherever they play.//// 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@@@ -23,6 +25,8 @@ const esc = seo.esc;const SITE = 'https://openvibe.network';const INVENTORY_SITE = 'https://inventory.openvibe.network';const QUEST_SITE = 'https://openvibe.quest';++const PLAY_SITE = 'https://play.openvibe.network';++const RECENT_ACHIEVEMENTS = 6; // per game on the page; the rest is on the game's page on OpenVibe.Playconst AUDIENCE = 'openvibe.inventory';const ITEMS_TTL_MS = 60_000;const KINDS_TTL_MS = 10 * 60_000;@@@ -47,10 +51,11 @@ 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', questUrl = 'http://127.0.0.1:4980', 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', playUrl = 'http://127.0.0.1:5040', 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 }++ const achievementsCache = new Map(); // subject → { at, value }let kindsHit = null;let lastWarn = '';@@@ -101,10 +106,39 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'httpreturn value;}-- /** What a public profile shows from other services: its items (Inventory) and its badges (Quest), read together. */++ /** A person's achievements in every game (Play), newest game first: { total, games: [{ title, name, count, recent }] }. */++ async function achievementsOf(subject) {++ if (!SUBJECT_RE.test(String(subject || ''))) return { total: 0, games: [] };++ const hit = achievementsCache.get(subject);++ if (hit && now() - hit.at < ITEMS_TTL_MS) return hit.value;++ let value;++ try {++ const r = await fetchImpl(`${String(playUrl).replace(/\/+$/, '')}/api/v1/people/${subject}/achievements`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });++ if (r.status === 404) value = { total: 0, games: [] };++ else {++ if (!r.ok) throw new Error(`answered ${r.status}`);++ const body = await r.json();++ const games = (Array.isArray(body.titles) ? body.titles : []).filter((t) => t && typeof t.title === 'string' && Array.isArray(t.achievements)).map((t) => ({++ title: t.title.slice(0, 64), name: String(t.name || t.title).slice(0, 80), count: t.achievements.length,++ url: `${PLAY_SITE}/t/${encodeURIComponent(t.title.slice(0, 64))}`,++ recent: t.achievements.slice(0, RECENT_ACHIEVEMENTS).filter((a) => a && typeof a.name === 'string').map((a) => ({ name: a.name.slice(0, 80), description: String(a.description || '').slice(0, 200), unlocked_at: typeof a.unlocked_at === 'string' ? a.unlocked_at : null })),++ }));++ value = { total: games.reduce((n, g) => n + g.count, 0), games };++ }++ } catch (err) {++ const m = `OpenVibe.Play: ${err.message}`;++ if (m !== lastWarn) { lastWarn = m; log.warn(`[Profiles] ${m}`); }++ return { total: 0, games: [], unavailable: true };++ }++ achievementsCache.set(subject, { at: now(), value });++ if (achievementsCache.size > 2000) achievementsCache.delete(achievementsCache.keys().next().value);++ return value;++ }++++ /** What a public profile shows from other services: items (Inventory), badges (Quest), achievements (Play), together. */async function wearsOf(subject) {-- const [inv, quest] = await Promise.all([itemsOf(subject), badgesOf(subject)]);-- return { ...inv, badges: quest.badges, badgesUnavailable: !!quest.unavailable };++ const [inv, quest, play] = await Promise.all([itemsOf(subject), badgesOf(subject), achievementsOf(subject)]);++ return { ...inv, badges: quest.badges, badgesUnavailable: !!quest.unavailable, achievements: play };}/** A person's items and what they wear, or { unavailable: true } while Inventory cannot answer. */@@@ -184,6 +218,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'httpinventory_url: SUBJECT_RE.test(String(u.subject_id || '')) ? `${INVENTORY_SITE}/u/${u.subject_id}` : null,badges: inv.badges,...(inv.badgesUnavailable ? { badges_unavailable: true } : {}),++ achievements: { total: inv.achievements.total, games: inv.achievements.games, ...(inv.achievements.unavailable ? { unavailable: true } : {}) },});}@@@ -212,7 +247,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http} catch (err) { next(err); }});-- return { pageHandler, api, find, profileOf, itemsOf, badgesOf };++ return { pageHandler, api, find, profileOf, itemsOf, badgesOf, achievementsOf };}// ── The page ───────────────────────────────────────────────────@@@ -241,6 +276,15 @@ function badgesHtml(inv) {<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>`;}++/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game on OpenVibe.Play. */++function gamesHtml(inv) {++ const a = inv.achievements;++ if (!a || !a.games.length) return '';++ return `<section class="pf-section" aria-labelledby="pf-games"><h2 id="pf-games">Games <span class="pf-count">${a.games.length}</span></h2>++<ul class="pf-games">${a.games.map((g) => `<li class="pf-game"><a class="pf-game-head" href="${esc(g.url)}"><span class="pf-game-name">${esc(g.name)}</span><span class="pf-game-n">${esc(plural(g.count, 'achievement', 'achievements'))}</span></a>++<ul class="pf-ach">${g.recent.map((x) => `<li title="${esc(x.description || x.name)}"><span aria-hidden="true">🏆</span> ${esc(x.name)}${x.unlocked_at && monthOf(x.unlocked_at) ? ` <small>${esc(monthOf(x.unlocked_at))}</small>` : ''}</li>`).join('')}</ul></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>`;@@@ -284,6 +328,7 @@ function renderPage(u, inv) {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.achievements && inv.achievements.total ? `<li><span class="pf-stat-k">Achievements</span><span class="pf-stat-v">${inv.achievements.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>` : '',].join('') : '';@@@ -360,6 +405,12 @@ a{color:var(--accent)}.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-games{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:12px}++.pf-game{border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary);padding:12px 14px;min-width:0}++.pf-game-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline;text-decoration:none;color:inherit;margin-bottom:8px}++.pf-game-name{font-weight:700;overflow-wrap:anywhere}.pf-game-n{font-size:.8rem;color:var(--text-muted);flex:none}++.pf-ach{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:minmax(0,1fr);gap:4px;font-size:.88rem}++.pf-ach li{overflow-wrap:anywhere}.pf-ach small{color:var(--text-muted);font-size:.75rem}.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))}.pf-badge small{display:none}.pf-badge{padding:6px 12px 6px 9px}}@@@ -378,7 +429,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) + badgesHtml(inv) + itemsHtml(inv, u) : ''}++${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + gamesHtml(inv) + itemsHtml(inv, u) : ''}</main>${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}<script src="/shared/navbar.js" defer></script>@@@ -63,13 +63,14 @@ 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' }, 'network.badge:badge': { instance_id: INSTANCES[3].id, definition_id: INSTANCES[3].definition_id, media_id: 'med_01JZ00000000000000000000B1' } } });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}/achievements`) return json(200, { subject: A, total: 3, titles: [{ title: 'salvage-rush', name: 'Salvage <Rush>', achievements: [{ id: 'big-haul', name: 'Big Haul', description: 'Bring back 50 parts', unlocked_at: '2026-10-20T10:00:00Z' }, { id: 'first-haul', name: 'First Haul', description: '', unlocked_at: '2026-10-19T10:00:00Z' }] }, { title: 'vibe-heroes', name: 'Vibe Heroes', achievements: [{ id: 'hello', name: 'Hello', unlocked_at: '2026-10-01T10:00:00Z' }] }] });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}`, questUrl: `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}`, playUrl: `http://127.0.0.1:${inv.address().port}`, now: () => clock, log: { warn() {} } });const app = express();app.get('/@:username', profiles.pageHandler);@@@ -96,7 +97,12 @@ 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.filter((c) => !c.path.includes('/badges')).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') && !c.path.endsWith('/achievements')).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('/achievements')).auth, '', 'Play\'s read is public: no token');++ assert.ok(r.text.includes('id="pf-games"') && r.text.includes('Salvage <Rush>') && !r.text.includes('Salvage <Rush>'), 'games, escaped');++ assert.ok(/pf-game-n">2 achievements<[\s\S]*Big Haul[\s\S]*First Haul[\s\S]*Vibe Heroes[\s\S]*1 achievement</.test(r.text), 'each game with its achievements, newest first');++ assert.ok(r.text.includes('href="https://play.openvibe.network/t/salvage-rush"'), 'a game links to its page on Play');++ assert.ok(/<span class="pf-stat-k">Achievements<\/span><span class="pf-stat-v">3<\/span>/.test(r.text));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');// The worn badge's image rule (.85em square) once shared the chips' class and squeezed every chip into it.@@@ -120,6 +126,7 @@ const INSTANCES = [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', 'network.badge']);assert.deepStrictEqual(p.badges.map((b) => b.id), ['thread-starter', 'first-follow']);++ assert.deepStrictEqual([p.achievements.total, p.achievements.games.map((g) => [g.title, g.count])], [3, [['salvage-rush', 2], ['vibe-heroes', 1]]], 'achievements in the JSON every site reads');// A hidden profile: name and picture, nothing else, not indexed.r = await get('/@hidden');@@@ -150,6 +157,7 @@ const INSTANCES = [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');++ assert.strictEqual(JSON.parse(r.text).profile.achievements.unavailable, true, 'and Play');console.log('public profiles: all checks passed');} finally {srv.close();@@@ -123,7 +123,7 @@ const REVIEWED = {'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],'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': [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/public.js fetch': [4, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030), OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) and OpenVibe.Play (OV_PLAY_INTERNAL_URL, loopback 5040) 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'],
@@ -657,7 +657,7 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)
@@ -657,7 +657,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', questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980' });
+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', playUrl: process.env.OV_PLAY_INTERNAL_URL || 'http://127.0.0.1:5040' });
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,7 +8,9 @@
@@ -8,7 +8,9 @@
// 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).
+// Badges come the same way from OpenVibe.Quest (GET /api/v1/profiles/:subject/badges, audience openvibe.quest), and
+// achievements from OpenVibe.Play's public read (GET /api/v1/people/:subject/achievements): every game's unlocks, so a
+// profile is the person's Steam-style page wherever they play.
//
//
// 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
@@ -23,6 +25,8 @@ const esc = seo.esc;
@@ -23,6 +25,8 @@ 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 QUEST_SITE = 'https://openvibe.quest';
+const PLAY_SITE = 'https://play.openvibe.network';
+const RECENT_ACHIEVEMENTS = 6; // per game on the page; the rest is on the game's page on OpenVibe.Play
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;
@@ -47,10 +51,11 @@ function monthOf(ts) {
@@ -47,10 +51,11 @@ 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', questUrl = 'http://127.0.0.1:4980', 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', playUrl = 'http://127.0.0.1:5040', 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 }
const badgesCache = new Map(); // subject → { at, value }
+ const achievementsCache = new Map(); // subject → { at, value }
let kindsHit = null;
let kindsHit = null;
let lastWarn = '';
let lastWarn = '';
@@ -101,10 +106,39 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -101,10 +106,39 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
return value;
return value;
}
}
- /** What a public profile shows from other services: its items (Inventory) and its badges (Quest), read together. */
+ /** A person's achievements in every game (Play), newest game first: { total, games: [{ title, name, count, recent }] }. */
+ async function achievementsOf(subject) {
+ if (!SUBJECT_RE.test(String(subject || ''))) return { total: 0, games: [] };
+ const hit = achievementsCache.get(subject);
+ if (hit && now() - hit.at < ITEMS_TTL_MS) return hit.value;
+ let value;
+ try {
+ const r = await fetchImpl(`${String(playUrl).replace(/\/+$/, '')}/api/v1/people/${subject}/achievements`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
+ if (r.status === 404) value = { total: 0, games: [] };
+ else {
+ if (!r.ok) throw new Error(`answered ${r.status}`);
+ const body = await r.json();
+ const games = (Array.isArray(body.titles) ? body.titles : []).filter((t) => t && typeof t.title === 'string' && Array.isArray(t.achievements)).map((t) => ({
+ title: t.title.slice(0, 64), name: String(t.name || t.title).slice(0, 80), count: t.achievements.length,
+ url: `${PLAY_SITE}/t/${encodeURIComponent(t.title.slice(0, 64))}`,
+ recent: t.achievements.slice(0, RECENT_ACHIEVEMENTS).filter((a) => a && typeof a.name === 'string').map((a) => ({ name: a.name.slice(0, 80), description: String(a.description || '').slice(0, 200), unlocked_at: typeof a.unlocked_at === 'string' ? a.unlocked_at : null })),
+ }));
+ value = { total: games.reduce((n, g) => n + g.count, 0), games };
+ }
+ } catch (err) {
+ const m = `OpenVibe.Play: ${err.message}`;
+ if (m !== lastWarn) { lastWarn = m; log.warn(`[Profiles] ${m}`); }
+ return { total: 0, games: [], unavailable: true };
+ }
+ achievementsCache.set(subject, { at: now(), value });
+ if (achievementsCache.size > 2000) achievementsCache.delete(achievementsCache.keys().next().value);
+ return value;
+ }
+
+ /** What a public profile shows from other services: items (Inventory), badges (Quest), achievements (Play), together. */
async function wearsOf(subject) {
async function wearsOf(subject) {
- const [inv, quest] = await Promise.all([itemsOf(subject), badgesOf(subject)]);
- return { ...inv, badges: quest.badges, badgesUnavailable: !!quest.unavailable };
+ const [inv, quest, play] = await Promise.all([itemsOf(subject), badgesOf(subject), achievementsOf(subject)]);
+ return { ...inv, badges: quest.badges, badgesUnavailable: !!quest.unavailable, achievements: play };
}
}
/** 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. */
@@ -184,6 +218,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -184,6 +218,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
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,
badges: inv.badges,
...(inv.badgesUnavailable ? { badges_unavailable: true } : {}),
...(inv.badgesUnavailable ? { badges_unavailable: true } : {}),
+ achievements: { total: inv.achievements.total, games: inv.achievements.games, ...(inv.achievements.unavailable ? { unavailable: true } : {}) },
});
});
}
}
@@ -212,7 +247,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
@@ -212,7 +247,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
} catch (err) { next(err); }
} catch (err) { next(err); }
});
});
- return { pageHandler, api, find, profileOf, itemsOf, badgesOf };
+ return { pageHandler, api, find, profileOf, itemsOf, badgesOf, achievementsOf };
}
}
// ── The page ───────────────────────────────────────────────────
// ── The page ───────────────────────────────────────────────────
@@ -241,6 +276,15 @@ function badgesHtml(inv) {
@@ -241,6 +276,15 @@ function badgesHtml(inv) {
<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>`;
<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>`;
}
}
+/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game on OpenVibe.Play. */
+function gamesHtml(inv) {
+ const a = inv.achievements;
+ if (!a || !a.games.length) return '';
+ return `<section class="pf-section" aria-labelledby="pf-games"><h2 id="pf-games">Games <span class="pf-count">${a.games.length}</span></h2>
+<ul class="pf-games">${a.games.map((g) => `<li class="pf-game"><a class="pf-game-head" href="${esc(g.url)}"><span class="pf-game-name">${esc(g.name)}</span><span class="pf-game-n">${esc(plural(g.count, 'achievement', 'achievements'))}</span></a>
+<ul class="pf-ach">${g.recent.map((x) => `<li title="${esc(x.description || x.name)}"><span aria-hidden="true">🏆</span> ${esc(x.name)}${x.unlocked_at && monthOf(x.unlocked_at) ? ` <small>${esc(monthOf(x.unlocked_at))}</small>` : ''}</li>`).join('')}</ul></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>`;
@@ -284,6 +328,7 @@ function renderPage(u, inv) {
@@ -284,6 +328,7 @@ function renderPage(u, inv) {
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.badges && inv.badges.length ? `<li><span class="pf-stat-k">Badges</span><span class="pf-stat-v">${inv.badges.length}</span></li>` : '',
+ inv.achievements && inv.achievements.total ? `<li><span class="pf-stat-k">Achievements</span><span class="pf-stat-v">${inv.achievements.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.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('') : '';
@@ -360,6 +405,12 @@ a{color:var(--accent)}
@@ -360,6 +405,12 @@ a{color:var(--accent)}
.pf-badge:hover,.pf-badge:focus-visible{border-color:var(--r-legendary)}
.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 small{font-weight:500;color:var(--text-muted);font-size:.75rem}
.pf-badge-ic{font-size:1.05rem}
.pf-badge-ic{font-size:1.05rem}
+.pf-games{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:12px}
+.pf-game{border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary);padding:12px 14px;min-width:0}
+.pf-game-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline;text-decoration:none;color:inherit;margin-bottom:8px}
+.pf-game-name{font-weight:700;overflow-wrap:anywhere}.pf-game-n{font-size:.8rem;color:var(--text-muted);flex:none}
+.pf-ach{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:minmax(0,1fr);gap:4px;font-size:.88rem}
+.pf-ach li{overflow-wrap:anywhere}.pf-ach small{color:var(--text-muted);font-size:.75rem}
.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))}.pf-badge small{display:none}.pf-badge{padding:6px 12px 6px 9px}}
@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))}.pf-badge small{display:none}.pf-badge{padding:6px 12px 6px 9px}}
@@ -378,7 +429,7 @@ ${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links
@@ -378,7 +429,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) + badgesHtml(inv) + itemsHtml(inv, u) : ''}
+${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + gamesHtml(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>
@@ -63,13 +63,14 @@ const INSTANCES = [
@@ -63,13 +63,14 @@ 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' }, 'network.badge:badge': { instance_id: INSTANCES[3].id, definition_id: INSTANCES[3].definition_id, media_id: 'med_01JZ00000000000000000000B1' } } });
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' }, 'network.badge:badge': { instance_id: INSTANCES[3].id, definition_id: INSTANCES[3].definition_id, media_id: 'med_01JZ00000000000000000000B1' } } });
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/people/${A}/achievements`) return json(200, { subject: A, total: 3, titles: [{ title: 'salvage-rush', name: 'Salvage <Rush>', achievements: [{ id: 'big-haul', name: 'Big Haul', description: 'Bring back 50 parts', unlocked_at: '2026-10-20T10:00:00Z' }, { id: 'first-haul', name: 'First Haul', description: '', unlocked_at: '2026-10-19T10:00:00Z' }] }, { title: 'vibe-heroes', name: 'Vibe Heroes', achievements: [{ id: 'hello', name: 'Hello', unlocked_at: '2026-10-01T10:00:00Z' }] }] });
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' }] });
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}`, questUrl: `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}`, playUrl: `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);
@@ -96,7 +97,12 @@ const INSTANCES = [
@@ -96,7 +97,12 @@ 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.filter((c) => !c.path.includes('/badges')).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') && !c.path.endsWith('/achievements')).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('/achievements')).auth, '', 'Play\'s read is public: no token');
+ assert.ok(r.text.includes('id="pf-games"') && r.text.includes('Salvage <Rush>') && !r.text.includes('Salvage <Rush>'), 'games, escaped');
+ assert.ok(/pf-game-n">2 achievements<[\s\S]*Big Haul[\s\S]*First Haul[\s\S]*Vibe Heroes[\s\S]*1 achievement</.test(r.text), 'each game with its achievements, newest first');
+ assert.ok(r.text.includes('href="https://play.openvibe.network/t/salvage-rush"'), 'a game links to its page on Play');
+ assert.ok(/<span class="pf-stat-k">Achievements<\/span><span class="pf-stat-v">3<\/span>/.test(r.text));
assert.strictEqual(calls.find((c) => c.path.endsWith('/badges')).auth, 'Bearer tok:openvibe.quest:quest.badge.read', 'and for Quest');
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(r.text.includes('id="pf-badges"') && /Thread <Starter>[\s\S]*First Follow/.test(r.text), 'badges, newest first, escaped');
// The worn badge's image rule (.85em square) once shared the chips' class and squeezed every chip into it.
// The worn badge's image rule (.85em square) once shared the chips' class and squeezed every chip into it.
@@ -120,6 +126,7 @@ const INSTANCES = [
@@ -120,6 +126,7 @@ const INSTANCES = [
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', 'network.badge']);
assert.deepStrictEqual(p.items.by_kind.map((k) => k.kind).sort(), ['live.hat', 'live.name_effect', 'live.particle', 'network.badge']);
assert.deepStrictEqual(p.badges.map((b) => b.id), ['thread-starter', 'first-follow']);
assert.deepStrictEqual(p.badges.map((b) => b.id), ['thread-starter', 'first-follow']);
+ assert.deepStrictEqual([p.achievements.total, p.achievements.games.map((g) => [g.title, g.count])], [3, [['salvage-rush', 2], ['vibe-heroes', 1]]], 'achievements in the JSON every site reads');
// 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');
@@ -150,6 +157,7 @@ const INSTANCES = [
@@ -150,6 +157,7 @@ const INSTANCES = [
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');
assert.strictEqual(JSON.parse(r.text).profile.badges_unavailable, true, 'Quest down too: said, not hidden');
+ assert.strictEqual(JSON.parse(r.text).profile.achievements.unavailable, true, 'and Play');
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/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'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': [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/public.js fetch': [4, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030), OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) and OpenVibe.Play (OV_PLAY_INTERNAL_URL, loopback 5040) 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'],