openvibers/openvibe.network Commits
Network: public profiles at /@<username> (picture, bio, member since, what they wear, their OpenVibe.Inventory items) and GET /api/v1/profiles/:username for every site; a person can hide theirs (plan T21 step 3) (#97)
Changes
@@@ -22,6 +22,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).- **Public discovery** — `/.well-known/openvibe`, `/api/v1/registry/*` and `/contracts/*.json` (services, capabilities, contract schemas) answer any origin with `Access-Control-Allow-Origin: *`, preflight included, so a browser app can discover the platform directly. Every other route keeps the first-party CORS allow-list (`server/public-cors.js`).++- **Public profiles** — `/@<username>`: one page per person with their picture, bio, when they joined, what they wear and the items they own in [OpenVibe.Inventory](https://inventory.openvibe.network) (plan T21). The same data is at `GET /api/v1/profiles/:username`, open to every origin. An old username answers 301 to the current one. A person can hide their profile from Edit Profile (`users.profile_public`); a hidden one shows only the name and picture and is not indexed. Items are read with the Network's own service token and kept 60 s per person (`server/profile/public.js`).- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.- **User modules** — versioned per-person preferences and summaries in namespaces owned by services (openvibe-contracts `manifests/namespaces`); see [User modules](#user-modules).nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- plan T21 step 3: every account has a public profile at openvibe.network/@<username> (picture, name, bio, member since,++-- what they wear and the items they own in OpenVibe.Inventory). A person may hide theirs: profile_public = 0 leaves only++-- their name and picture, unindexed. Public by default, as the Live channel pages and Inventory's /u/<subject> already are.++-- Additive: a release from before this one never reads the column.++ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_public bigint NOT NULL DEFAULT 1;@@@ -117,6 +117,11 @@ function renderProfile(user) {document.getElementById('pf-bio').value = user.bio || '';document.getElementById('pf-email').value = user.email || '';document.getElementById('pf-color').value = user.profile_color || '#8b5cf6';++ const pub = document.getElementById('pf-public');++ pub.checked = user.profile_public === undefined || Number(user.profile_public) !== 0;++ pub.disabled = !!user.is_anon;++ const link = document.getElementById('pf-public-link');++ if (user.username && !user.is_anon) { link.href = '/@' + encodeURIComponent(user.username); link.textContent = 'openvibe.network/@' + user.username; }if (!user.is_anon) {document.getElementById('pf-bio').disabled = false;document.getElementById('pf-email').disabled = false;@@@ -194,6 +199,7 @@ document.getElementById('profile-form').addEventListener('submit', async (e) =>bio: document.getElementById('pf-bio').value,email: document.getElementById('pf-email').value || undefined,profile_color: document.getElementById('pf-color').value,++ profile_public: document.getElementById('pf-public').checked,})});currentUser = data.user;renderHeader(data.user);@@@ -108,6 +108,10 @@<label>Profile Color</label><input type="color" id="pf-color" value="#8b5cf6" style="width:60px;height:36px;padding:2px;cursor:pointer"></div>++ <div class="form-field">++ <label>Public Profile</label>++ <label class="data-check"><input type="checkbox" id="pf-public" checked> <span>Show my profile at <a id="pf-public-link" href="/my" target="_blank" rel="noopener">openvibe.network/@you</a>: your picture, bio, when you joined, and the items you own and wear. When this is off, it shows only your name and picture.</span></label>++ </div><div class="btn-row"><button type="submit" class="btn btn-primary">Save Changes</button></div>@@@ -396,7 +396,7 @@ router.get('/me', requireAuth, async (req, res) => {// ── Update Profile ───────────────────────────────────────────router.put('/profile', requireAuth, async (req, res) => {const db = await getDb(req);-- const { display_name, bio, avatar_url, email, profile_color } = req.body;++ const { display_name, bio, avatar_url, email, profile_color, profile_public } = req.body;const updates = [];const params = [];@@@ -425,6 +425,8 @@ router.put('/profile', requireAuth, async (req, res) => {updates.push('email = ?'); params.push(next);}if (profile_color !== undefined) { updates.push('profile_color = ?'); params.push(profile_color); }++ // openvibe.network/@you (server/profile/public.js): shown to everyone, or only your name and picture.++ if (profile_public !== undefined) { updates.push('profile_public = ?'); params.push(profile_public ? 1 : 0); }if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });@@@ -678,6 +678,11 @@ const avatarService = require('./profile/avatar').createAvatarService({ db, confapp.use('/api/profile/avatar', rateLimit({ windowMs: 60_000, max: 20 }), avatarService.api);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' });++app.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);++app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles.pageHandler);// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.nnew file mode 100644@@@ -0,0 +1,324 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// Public profiles (plan T21 step 3): one page per person on the Network, and the same data as JSON for every site.++//++// GET /@:username HTML: picture, name, bio, member since, what they wear, the items they own++// GET /api/v1/profiles/:username JSON, public and CORS-open (server/public-cors.js): profile@1 below++//++// 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.++//++// 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++// Profile card): the page then shows only their name and picture, is not indexed, and the JSON says private: true.++// ═══════════════════════════════════════════════════════════════++const express = require('express');++const seo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');++const usernames = require('../identity/usernames');++++const esc = seo.esc;++const SITE = 'https://openvibe.network';++const INVENTORY_SITE = 'https://inventory.openvibe.network';++const AUDIENCE = 'openvibe.inventory';++const ITEMS_TTL_MS = 60_000;++const KINDS_TTL_MS = 10 * 60_000;++const TIMEOUT_MS = 4000;++const MAX_ITEMS = 400; // two pages of Inventory's 200; the rest is one link away on OpenVibe.Inventory++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const COLOR_RE = /^#[0-9a-fA-F]{6}$/;++const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];++const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };++const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];++++const profileUrl = (username) => `${SITE}/@${encodeURIComponent(username)}`;++const avatarUrl = (username, size = 160) => `${SITE}/avatar/${encodeURIComponent(username)}?s=${size}`;++const itemUrl = (definitionId) => `${INVENTORY_SITE}/items/${encodeURIComponent(definitionId)}`;++const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;++const rarityRank = (r) => Math.max(0, RARITY.indexOf(r));++++/** 'YYYY-MM-DD …' → 'October 2026' (null when the date is unusable). */++function monthOf(ts) {++ const m = /^(\d{4})-(\d{2})/.exec(String(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 } = {}) {++ const base = String(inventoryUrl).replace(/\/+$/, '');++ const itemsCache = new Map(); // subject → { at, value }++ let kindsHit = null;++ let lastWarn = '';++++ async function inventoryGet(path) {++ const token = selfToken(AUDIENCE, ['inventory.item.read']);++ const r = await fetchImpl(`${base}/api/v1${path}`, {++ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },++ signal: AbortSignal.timeout(TIMEOUT_MS),++ });++ if (!r.ok) throw new Error(`answered ${r.status}`);++ return await r.json();++ }++++ async function kinds() {++ if (kindsHit && now() - kindsHit.at < KINDS_TTL_MS) return kindsHit.value;++ const out = await inventoryGet('/kinds');++ const value = new Map((out.kinds || []).map((k) => [k.id, k]));++ kindsHit = { at: now(), value };++ return value;++ }++++ /** 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 };++ const hit = itemsCache.get(subject);++ if (hit && now() - hit.at < ITEMS_TTL_MS) return hit.value;++ let value;++ try {++ const [kindMap, worn] = await Promise.all([kinds(), inventoryGet(`/people/${subject}/equipped`)]);++ const instances = [];++ const definitions = {};++ let cursor = null;++ let more = false;++ do {++ const page = await inventoryGet(`/people/${subject}/items?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);++ instances.push(...(page.instances || []));++ Object.assign(definitions, page.definitions || {});++ cursor = page.next_cursor || null;++ more = !!cursor && instances.length >= MAX_ITEMS;++ } while (cursor && instances.length < MAX_ITEMS);++ const kindOf = (id) => kindMap.get(id) || { id, name: id };++ const items = instances.filter((i) => definitions[i.definition_id]).map((i) => {++ const d = definitions[i.definition_id];++ return { instance_id: i.id, definition_id: d.id, name: d.name, rarity: RARITY_LABEL[d.rarity] ? d.rarity : 'common', kind: d.kind, kind_name: kindOf(d.kind).name, art: d.art || {}, acquired_at: i.acquired_at || null, url: itemUrl(d.id) };++ });++ const byInstance = new Map(items.map((i) => [i.instance_id, i]));++ const showcase = [];++ for (const [key, slot] of Object.entries((worn && worn.slots) || {})) {++ const item = byInstance.get(slot.instance_id);++ if (item) showcase.push({ slot: key.split(':')[1] || key, ...item });++ }++ const order = [...kindMap.keys()];++ showcase.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || a.name.localeCompare(b.name));++ value = { items, showcase, total: items.length, more };++ lastWarn = '';++ } catch (err) {++ const m = `OpenVibe.Inventory: ${err.message}`;++ if (m !== lastWarn) { lastWarn = m; log.warn(`[Profiles] ${m}`); }++ return { items: [], showcase: [], total: 0, more: false, unavailable: true };++ }++ itemsCache.set(subject, { at: now(), value });++ if (itemsCache.size > 2000) itemsCache.delete(itemsCache.keys().next().value);++ return value;++ }++++ /** → { moved: current } | { user } | null */++ async function find(name) {++ const rec = await usernames.lookup(db, name);++ if (!rec) return null;++ if (rec.renamed) return { moved: rec.current };++ const u = await db.prepare(`SELECT id, username, display_name, bio, profile_color, created_at, subject_id, profile_public,++ is_anon, merged_into, deleted_at FROM users WHERE id = ?`).get(rec.network_id);++ if (!u || Number(u.is_anon) || u.merged_into || u.deleted_at) return null;++ return { user: u };++ }++++ /** profile@1: what every site may show about a person. */++ async function profileOf(u) {++ const out = {++ username: u.username,++ display_name: u.display_name || u.username,++ avatar_url: avatarUrl(u.username),++ profile_url: profileUrl(u.username),++ private: !Number(u.profile_public),++ };++ if (out.private) return out;++ const inv = await itemsOf(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, {++ bio: String(u.bio || '').slice(0, 500),++ color: COLOR_RE.test(String(u.profile_color || '')) ? u.profile_color : null,++ member_since: String(u.created_at || '').slice(0, 10) || null,++ 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,++ });++ }++++ /** GET /@:username (server/index.js mounts it with its own rate limit). */++ async function pageHandler(req, res, next) {++ try {++ const found = await find(req.params.username);++ 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;++ res.set('Content-Type', 'text/html; charset=utf-8');++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));++ res.send(renderPage(u, inv));++ } catch (err) { next(err); }++ }++++ const api = express.Router();++ api.get('/:username', async (req, res, next) => {++ try {++ const found = await find(req.params.username);++ res.set('Cache-Control', 'public, max-age=60');++ if (!found) return res.status(404).json({ error: 'not_found' });++ if (found.moved) return res.status(301).set('Location', `/api/v1/profiles/${encodeURIComponent(found.moved)}`).json({ moved_to: found.moved });++ res.json({ profile: await profileOf(found.user) });++ } catch (err) { next(err); }++ });++++ return { pageHandler, api, find, profileOf, itemsOf };++}++++// ── The page ───────────────────────────────────────────────────++++/** The item's picture: a Media image, or its emoji, on a square tinted by rarity. */++function art(i, size = 'md') {++ const a = i.art || {};++ if (a.media_id && /^[A-Za-z0-9_-]{1,80}$/.test(String(a.media_id))) {++ return `<span class="pf-art pf-art-${size} r-${i.rarity}"><img src="https://openvibe.media/o/${esc(a.media_id)}" alt="" loading="lazy" width="64" height="64"></span>`;++ }++ return `<span class="pf-art pf-art-${size} r-${i.rarity}" aria-hidden="true">${esc(String(a.emoji || '◆').slice(0, 8))}</span>`;++}++const rarityBadge = (r) => `<span class="pf-rarity r-${esc(r)}">${esc(RARITY_LABEL[r] || r)}</span>`;++++function showcaseHtml(inv, name) {++ if (!inv.showcase.length) return '';++ return `<section class="pf-section" aria-labelledby="pf-wearing"><h2 id="pf-wearing">Wearing</h2>++<ul class="pf-showcase">${inv.showcase.map((i) => `<li class="pf-worn r-${esc(i.rarity)}"><a href="${esc(i.url)}">${art(i, 'lg')}<span class="pf-worn-body"><span class="pf-kind">${esc(i.kind_name)}</span><span class="pf-name">${esc(i.name)}</span>${rarityBadge(i.rarity)}</span></a></li>`).join('')}</ul>++<p class="pf-note">What ${esc(name)} shows in chat, on stream overlays and here.</p></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>`;++ if (!inv.items.length) return `<section class="pf-section" aria-labelledby="pf-items"><h2 id="pf-items">Items</h2><p class="pf-empty">${name} has no items yet. Items come from streaming, chatting and playing on OpenVibe sites. <a href="${INVENTORY_SITE}/items">See what there is</a>.</p></section>`;++ const groups = new Map();++ for (const i of inv.items) {++ if (!groups.has(i.kind)) groups.set(i.kind, { name: i.kind_name, list: [] });++ groups.get(i.kind).list.push(i);++ }++ const worn = new Set(inv.showcase.map((i) => i.instance_id));++ const sorted = [...groups.values()].sort((a, b) => b.list.length - a.list.length || a.name.localeCompare(b.name));++ const all = SUBJECT_RE.test(String(u.subject_id || '')) ? `${INVENTORY_SITE}/u/${u.subject_id}` : `${INVENTORY_SITE}/`;++ return `<section class="pf-section" aria-labelledby="pf-items"><h2 id="pf-items">Items <span class="pf-count">${inv.more ? `${inv.total}+` : inv.total}</span></h2>++${sorted.map((g) => {++ g.list.sort((a, b) => rarityRank(b.rarity) - rarityRank(a.rarity) || a.name.localeCompare(b.name));++ return `<h3 class="pf-kind-head">${esc(g.name)} <span class="pf-count">${g.list.length}</span></h3>++<ul class="pf-grid">${g.list.map((i) => `<li class="pf-card r-${esc(i.rarity)}${worn.has(i.instance_id) ? ' is-worn' : ''}"><a href="${esc(i.url)}" title="${esc(`${i.name} · ${RARITY_LABEL[i.rarity]}`)}">${art(i)}<span class="pf-card-name">${esc(i.name)}</span>${rarityBadge(i.rarity)}${worn.has(i.instance_id) ? '<span class="pf-worn-tag">Worn</span>' : ''}</a></li>`).join('')}</ul>`;++ }).join('\n')}++<p class="pf-note"><a href="${esc(all)}">${inv.more ? 'Every item' : 'This inventory'} on OpenVibe.Inventory</a> · <a href="${INVENTORY_SITE}/items">Every item there is</a></p></section>`;++}++++function renderPage(u, inv) {++ const name = u.display_name || u.username;++ const isPublic = !!inv;++ const color = COLOR_RE.test(String(u.profile_color || '')) ? u.profile_color : '#3b82f6';++ const since = monthOf(u.created_at);++ const bio = isPublic ? String(u.bio || '').trim().slice(0, 500) : '';++ const title = `${name} (@${u.username}) · OpenVibe`;++ const description = !isPublic++ ? `${name} on OpenVibe. This profile is private.`++ : (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.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('') : '';++ const ld = isPublic ? seo.jsonLdTag({++ '@context': 'https://schema.org', '@type': 'ProfilePage', url: profileUrl(u.username), name: title,++ ...(String(u.created_at || '').length >= 10 ? { dateCreated: String(u.created_at).slice(0, 10) } : {}),++ mainEntity: { '@type': 'Person', name, alternateName: `@${u.username}`, image: avatarUrl(u.username, 256), url: profileUrl(u.username), ...(bio ? { description: bio } : {}) },++ }) : '';++ return `<!DOCTYPE html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>${esc(title)}</title>++<meta name="description" content="${esc(description)}">++<link rel="canonical" href="${esc(profileUrl(u.username))}">++${isPublic ? '' : '<meta name="robots" content="noindex">'}++<meta property="og:type" content="profile">++<meta property="og:title" content="${esc(title)}">++<meta property="og:description" content="${esc(description)}">++<meta property="og:url" content="${esc(profileUrl(u.username))}">++<meta property="og:image" content="${esc(avatarUrl(u.username, 256))}">++<meta property="profile:username" content="${esc(u.username)}">++<link rel="alternate" type="application/json" href="/api/v1/profiles/${esc(encodeURIComponent(u.username))}">++${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}++<meta name="color-scheme" content="dark light">++<script src="/shared/theme-loader.js" defer></script>++${ld}++<style>++*,*::before,*::after{box-sizing:border-box}++:root{--bg-primary:#0a0f1c;--bg-secondary:#101828;--bg-card:#0f172a;--border:#1f2d47;--accent:#3b82f6;--text-primary:#e6edf7;--text-secondary:#96a7c2;--text-muted:#7386a3;++--r-common:#94a3b8;--r-uncommon:#22c55e;--r-rare:#3b82f6;--r-epic:#a855f7;--r-legendary:#f59e0b}++html,body{margin:0;background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}++a{color:var(--accent)}++.pf{max-width:1040px;margin:0 auto;padding:20px 16px 48px}++.pf-hero{position:relative;border:1px solid var(--border);border-radius:18px;overflow:hidden;background:var(--bg-secondary)}++.pf-banner{height:96px;background:linear-gradient(120deg,color-mix(in srgb,var(--pf-color) 70%,transparent),color-mix(in srgb,var(--pf-color) 18%,transparent) 60%,transparent),var(--bg-card)}++.pf-head{display:flex;gap:20px;align-items:flex-end;padding:0 24px 20px;margin-top:-52px;flex-wrap:wrap}++.pf-avatar{width:112px;height:112px;border-radius:50%;object-fit:cover;border:4px solid var(--bg-secondary);box-shadow:0 0 0 2px var(--pf-color);background:var(--bg-card);flex:none}++.pf-who{flex:1;min-width:200px;padding-top:56px}++.pf-who h1{margin:0;font-size:clamp(1.5rem,4vw,2.1rem);line-height:1.15;overflow-wrap:anywhere}++.pf-handle{margin:.2em 0 0;color:var(--text-secondary)}++.pf-bio{margin:0;padding:0 24px 18px;color:var(--text-primary);line-height:1.6;white-space:pre-line;overflow-wrap:anywhere;max-width:72ch}++.pf-stats{list-style:none;display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0 24px 22px}++.pf-stats li{display:flex;flex-direction:column;gap:2px;padding:8px 14px;border:1px solid var(--border);border-radius:12px;background:var(--bg-card);min-width:110px}++.pf-stat-k{font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)}++.pf-stat-v{font-weight:700}++.pf-private{margin:0;padding:0 24px 24px;color:var(--text-secondary)}++.pf-section{margin-top:28px}++.pf-section h2{font-size:1.15rem;margin:0 0 12px;display:flex;align-items:center;gap:8px}++.pf-kind-head{font-size:.85rem;text-transform:uppercase;letter-spacing:.06em;color:var(--text-secondary);margin:20px 0 10px;display:flex;align-items:center;gap:8px}++.pf-count{font-size:.75rem;font-weight:700;padding:.1rem .5rem;border-radius:999px;background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--text-primary);letter-spacing:0;text-transform:none}++.pf-showcase{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}++.pf-worn a{display:flex;gap:14px;align-items:center;padding:14px;border-radius:14px;text-decoration:none;color:inherit;border:1px solid color-mix(in srgb,var(--rc) 45%,var(--border));background:linear-gradient(135deg,color-mix(in srgb,var(--rc) 14%,var(--bg-secondary)),var(--bg-secondary))}++.pf-worn a:hover,.pf-worn a:focus-visible{border-color:var(--rc)}++.pf-worn-body{display:flex;flex-direction:column;gap:3px;min-width:0}++.pf-kind{font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)}++.pf-name{font-weight:700;overflow-wrap:anywhere}++.pf-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:10px}++.pf-card a{position:relative;display:flex;flex-direction:column;align-items:center;gap:6px;padding:12px 10px;border-radius:12px;border:1px solid var(--border);border-bottom:3px solid var(--rc);background:var(--bg-secondary);text-decoration:none;color:inherit;text-align:center;height:100%}++.pf-card a:hover,.pf-card a:focus-visible{border-color:var(--rc);background:color-mix(in srgb,var(--rc) 8%,var(--bg-secondary))}++.pf-card-name{font-size:.85rem;font-weight:600;overflow-wrap:anywhere}++.pf-worn-tag{position:absolute;top:6px;right:6px;font-size:.62rem;font-weight:700;padding:.05rem .4rem;border-radius:999px;background:var(--accent);color:var(--on-accent,#fff)}++.pf-art{display:inline-flex;align-items:center;justify-content:center;border-radius:12px;background:radial-gradient(circle at 50% 35%,color-mix(in srgb,var(--rc) 30%,transparent),color-mix(in srgb,var(--rc) 8%,var(--bg-card)));flex:none;overflow:hidden}++.pf-art-md{width:56px;height:56px;font-size:28px}.pf-art-lg{width:68px;height:68px;font-size:34px}++.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-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-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))}}++</style>++</head>++<body>++<div id="navbar-mount"></div>++${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Inventory', href: `${INVENTORY_SITE}/` }] })}++<main class="pf" id="main" style="--pf-color:${esc(color)}">++<section class="pf-hero" aria-label="Profile">++<div class="pf-banner" aria-hidden="true"></div>++<div class="pf-head">++<img class="pf-avatar" src="${esc(avatarUrl(u.username))}" alt="" width="112" height="112">++<div class="pf-who"><h1>${esc(name)}</h1><p class="pf-handle">@${esc(u.username)}</p></div>++</div>++${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) : ''}++</main>++${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}++<script src="/shared/navbar.js" defer></script>++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>++</body>++</html>`;++}++++module.exports = { createPublicProfiles, renderPage, monthOf, SUBJECT_RE };@@@ -7,6 +7,7 @@* GET /.well-known/openvibe* GET /api/v1/registry and /api/v1/registry/<...>* GET /contracts/<domain>/<name>.v<N>.json (and /contracts/events/payloads/<event_type>.v<N>.json)++ * GET /api/v1/profiles/<username> a person's public profile (server/profile/public.js)** These answer every origin, preflight included, with `Access-Control-Allow-Origin: *` and no* credentials (cookies are never needed and never sent). Every other route keeps the first-party@@@ -26,6 +27,7 @@ function isPublicDiscoveryPath(p) {const path = String(p || '');if (path === '/.well-known/openvibe') return true;if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads++ if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every siteif (CONTRACT_RE.test(path)) return true;if (path === '/api/v1/registry' || path === '/api/v1/registry/') return true;if (!path.startsWith('/api/v1/registry/')) return false;@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.nnew file mode 100644@@@ -0,0 +1,143 @@++'use strict';++// Public profiles (server/profile/public.js, plan T21 step 3): openvibe.network/@<username> and++// GET /api/v1/profiles/:username, with the items from a stand-in OpenVibe.Inventory read with the Network's own++// service token. A public profile shows the picture, name, escaped bio, member since, what the person wears and their++// items by kind; a hidden one only the name and picture (noindex); an anonymous or deleted account is a 404; an old++// username answers 301; Inventory down still answers the profile; a second view within a minute reads nothing again.++// node test/public-profile.test.js++const assert = require('assert');++const http = require('http');++const fs = require('fs');++const path = require('path');++const express = require('express');++const { getDb } = require('../server/db/database');++const { createPublicProfiles, monthOf } = require('../server/profile/public');++const publicCors = require('../server/public-cors');++++const A = 'usr_01JZ0000000000000000000AAA';++const KINDS = [++ { id: 'live.name_effect', name: 'Name effect' }, { id: 'live.hat', name: 'Hat' }, { id: 'live.particle', name: 'Particle' },++];++const DEFS = {++ itd_01JZ00000000000000000001R1: { id: 'itd_01JZ00000000000000000001R1', kind: 'live.hat', name: 'Royal Crown', rarity: 'legendary', art: { emoji: '👑' } },++ itd_01JZ00000000000000000001R2: { id: 'itd_01JZ00000000000000000001R2', kind: 'live.name_effect', name: 'Rainbow', rarity: 'epic', art: { emoji: '🌈' } },++ itd_01JZ00000000000000000001R3: { id: 'itd_01JZ00000000000000000001R3', kind: 'live.particle', name: 'Sparkles <b>', rarity: 'common', art: { emoji: '✨' } },++};++const INSTANCES = [++ { id: 'inv_01JZ0000000000000000000001', definition_id: 'itd_01JZ00000000000000000001R1', state: 'owned', acquired_at: '2026-10-02T00:00:00Z' },++ { id: 'inv_01JZ0000000000000000000002', definition_id: 'itd_01JZ00000000000000000001R2', state: 'owned', acquired_at: '2026-10-03T00:00:00Z' },++ { id: 'inv_01JZ0000000000000000000003', definition_id: 'itd_01JZ00000000000000000001R3', state: 'owned', acquired_at: '2026-10-04T00:00:00Z' },++];++++(async () => {++ assert.strictEqual(monthOf('2026-10-01 10:00:00'), 'October 2026');++ assert.strictEqual(monthOf('bad'), null);++ for (const p of ['/api/v1/profiles/Ana', '/api/v1/profiles/some_one_42']) assert.ok(publicCors.isPublicDiscoveryPath(p), `${p} is readable from any origin`);++ for (const p of ['/api/v1/profiles', '/api/v1/profiles/', '/api/v1/profiles/a/b', '/api/v1/profiles/a.b', '/api/v1/profiles/%2e%2e']) assert.ok(!publicCors.isPublicDiscoveryPath(p), `${p} is not`);++++ // The route is mounted before the 404 page, and the column comes from a migration.++ const index = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');++ assert.ok(index.indexOf("app.get('/@:username'") > 0 && index.indexOf("app.get('/@:username'") < index.indexOf("require('./not-found').notFound"), '/@:username answers before the 404 page');++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'migrations', '0022_public_profiles.sql'), 'utf8'), /^-- phase: expand$/m);++++ const db = getDb();++ await db.prepare(`INSERT INTO users (username, password_hash, display_name, bio, profile_color, created_at, subject_id, profile_public, is_anon, deleted_at) VALUES++ ('Ana', 'x', 'Ana', 'I stream <script>alert(1)</script> & paint', '#ff0066', '2026-10-01 10:00:00', '${A}', 1, 0, NULL),++ ('hidden', 'x', 'Hidden', 'secret bio', '#00ff00', '2026-09-01 10:00:00', 'usr_01JZ0000000000000000000BBB', 0, 0, NULL),++ ('ghost', 'x', 'Ghost', '', NULL, '2026-09-01 10:00:00', NULL, 1, 1, NULL),++ ('gone', 'x', 'Gone', '', NULL, '2026-09-01 10:00:00', NULL, 1, 0, '2026-10-05 00:00:00')`).run();++ const ana = await db.prepare("SELECT id FROM users WHERE username = 'Ana'").get();++ await db.prepare("INSERT INTO username_history (user_id, old_username, new_username) VALUES (?, 'oldana', 'Ana')").run(ana.id);++ assert.strictEqual(Number((await db.prepare("SELECT profile_public FROM users WHERE username = 'Ana'").get()).profile_public), 1);++++ // A stand-in OpenVibe.Inventory.++ const calls = [];++ let down = false;++ const inv = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ calls.push({ path: u.pathname, auth: req.headers.authorization || '' });++ const json = (s, o) => { res.writeHead(s, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };++ if (down) return json(503, { code: 'down' });++ 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 } } });++ if (u.pathname === `/api/v1/people/${A}/items`) return json(200, { subject: A, instances: INSTANCES, definitions: DEFS, next_cursor: null });++ 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 app = express();++ app.get('/@:username', profiles.pageHandler);++ app.use('/api/v1/profiles', profiles.api);++ app.use((req, res) => res.status(404).send('not found'));++ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });++ const b = `http://127.0.0.1:${srv.address().port}`;++ const get = async (p) => { const r = await fetch(b + p, { redirect: 'manual' }); return { status: r.status, headers: r.headers, text: await r.text() }; };++++ try {++ // A public profile.++ let r = await get('/@ana');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('<h1>Ana</h1>') && r.text.includes('@Ana'));++ assert.ok(!r.text.includes('<script>alert(1)</script>') && r.text.includes('I stream <script>alert(1)</script> & paint'), 'the bio is escaped');++ assert.ok(r.text.includes('Sparkles <b>') && !r.text.includes('Sparkles <b>'), 'item names are escaped');++ assert.ok(r.text.includes('id="pf-wearing"') && /pf-worn r-legendary[\s\S]*Royal Crown/.test(r.text), 'the crown is in the showcase');++ assert.ok(r.text.includes('Member since') && r.text.includes('October 2026'));++ assert.ok(/<span class="pf-stat-k">Rarest<\/span><span class="pf-stat-v">Legendary<\/span>/.test(r.text));++ assert.ok(r.text.includes('<span class="pf-count">3</span>'), 'three items');++ assert.ok(r.text.includes('"@type":"ProfilePage"') && r.text.includes('<link rel="canonical" href="https://openvibe.network/@Ana">'));++ 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');++++ // Cached for a minute: a second view reads nothing.++ const n = calls.length;++ await get('/@Ana');++ assert.strictEqual(calls.length, n, 'a second view within 60 s is cache');++++ // The JSON every site reads.++ r = await get('/api/v1/profiles/Ana');++ assert.strictEqual(r.status, 200);++ const p = JSON.parse(r.text).profile;++ assert.deepStrictEqual([p.username, p.private, p.color, p.member_since, p.items.count, p.showcase.map((s) => s.name)], ['Ana', false, '#ff0066', '2026-10-01', 3, ['Royal Crown']]);++ 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']);++++ // A hidden profile: name and picture, nothing else, not indexed.++ r = await get('/@hidden');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('This profile is private.') && r.text.includes('name="robots" content="noindex"'));++ assert.ok(!r.text.includes('secret bio') && !r.text.includes('Member since') && !r.text.includes('class="pf-grid"'));++ r = await get('/api/v1/profiles/hidden');++ assert.deepStrictEqual(JSON.parse(r.text).profile, { username: 'hidden', display_name: 'Hidden', avatar_url: 'https://openvibe.network/avatar/hidden?s=160', profile_url: 'https://openvibe.network/@hidden', private: true });++++ // No profile: anonymous, deleted, unknown.++ for (const name of ['ghost', 'gone', 'nobody', 'x']) {++ assert.strictEqual((await get(`/@${name}`)).status, 404, `/@${name}`);++ assert.strictEqual((await get(`/api/v1/profiles/${name}`)).status, 404, `api ${name}`);++ }++ // An old name: 301 to the current one.++ r = await get('/@oldana');++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, '/@Ana']);++ r = await get('/api/v1/profiles/oldana');++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, '/api/v1/profiles/Ana']);++++ // Inventory down (and the cache expired): the profile still answers.++ down = true;++ clock += 61_000;++ r = await get('/@Ana');++ assert.strictEqual(r.status, 200);++ 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);++ console.log('public profiles: all checks passed');++ } finally {++ srv.close();++ inv.close();++ }++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -123,6 +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/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'],@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -22,6 +22,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -22,6 +22,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.
- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.
- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).
- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).
- **Public discovery** — `/.well-known/openvibe`, `/api/v1/registry/*` and `/contracts/*.json` (services, capabilities, contract schemas) answer any origin with `Access-Control-Allow-Origin: *`, preflight included, so a browser app can discover the platform directly. Every other route keeps the first-party CORS allow-list (`server/public-cors.js`).
- **Public discovery** — `/.well-known/openvibe`, `/api/v1/registry/*` and `/contracts/*.json` (services, capabilities, contract schemas) answer any origin with `Access-Control-Allow-Origin: *`, preflight included, so a browser app can discover the platform directly. Every other route keeps the first-party CORS allow-list (`server/public-cors.js`).
+- **Public profiles** — `/@<username>`: one page per person with their picture, bio, when they joined, what they wear and the items they own in [OpenVibe.Inventory](https://inventory.openvibe.network) (plan T21). The same data is at `GET /api/v1/profiles/:username`, open to every origin. An old username answers 301 to the current one. A person can hide their profile from Edit Profile (`users.profile_public`); a hidden one shows only the name and picture and is not indexed. Items are read with the Network's own service token and kept 60 s per person (`server/profile/public.js`).
- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.
- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.
- **User modules** — versioned per-person preferences and summaries in namespaces owned by services (openvibe-contracts `manifests/namespaces`); see [User modules](#user-modules).
- **User modules** — versioned per-person preferences and summaries in namespaces owned by services (openvibe-contracts `manifests/namespaces`); see [User modules](#user-modules).
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- plan T21 step 3: every account has a public profile at openvibe.network/@<username> (picture, name, bio, member since,
+-- what they wear and the items they own in OpenVibe.Inventory). A person may hide theirs: profile_public = 0 leaves only
+-- their name and picture, unindexed. Public by default, as the Live channel pages and Inventory's /u/<subject> already are.
+-- Additive: a release from before this one never reads the column.
+ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_public bigint NOT NULL DEFAULT 1;
@@ -117,6 +117,11 @@ function renderProfile(user) {
@@ -117,6 +117,11 @@ function renderProfile(user) {
document.getElementById('pf-bio').value = user.bio || '';
document.getElementById('pf-bio').value = user.bio || '';
document.getElementById('pf-email').value = user.email || '';
document.getElementById('pf-email').value = user.email || '';
document.getElementById('pf-color').value = user.profile_color || '#8b5cf6';
document.getElementById('pf-color').value = user.profile_color || '#8b5cf6';
+ const pub = document.getElementById('pf-public');
+ pub.checked = user.profile_public === undefined || Number(user.profile_public) !== 0;
+ pub.disabled = !!user.is_anon;
+ const link = document.getElementById('pf-public-link');
+ if (user.username && !user.is_anon) { link.href = '/@' + encodeURIComponent(user.username); link.textContent = 'openvibe.network/@' + user.username; }
if (!user.is_anon) {
if (!user.is_anon) {
document.getElementById('pf-bio').disabled = false;
document.getElementById('pf-bio').disabled = false;
document.getElementById('pf-email').disabled = false;
document.getElementById('pf-email').disabled = false;
@@ -194,6 +199,7 @@ document.getElementById('profile-form').addEventListener('submit', async (e) =>
@@ -194,6 +199,7 @@ document.getElementById('profile-form').addEventListener('submit', async (e) =>
bio: document.getElementById('pf-bio').value,
bio: document.getElementById('pf-bio').value,
email: document.getElementById('pf-email').value || undefined,
email: document.getElementById('pf-email').value || undefined,
profile_color: document.getElementById('pf-color').value,
profile_color: document.getElementById('pf-color').value,
+ profile_public: document.getElementById('pf-public').checked,
})});
})});
currentUser = data.user;
currentUser = data.user;
renderHeader(data.user);
renderHeader(data.user);
@@ -108,6 +108,10 @@
@@ -108,6 +108,10 @@
<label>Profile Color</label>
<label>Profile Color</label>
<input type="color" id="pf-color" value="#8b5cf6" style="width:60px;height:36px;padding:2px;cursor:pointer">
<input type="color" id="pf-color" value="#8b5cf6" style="width:60px;height:36px;padding:2px;cursor:pointer">
</div>
</div>
+ <div class="form-field">
+ <label>Public Profile</label>
+ <label class="data-check"><input type="checkbox" id="pf-public" checked> <span>Show my profile at <a id="pf-public-link" href="/my" target="_blank" rel="noopener">openvibe.network/@you</a>: your picture, bio, when you joined, and the items you own and wear. When this is off, it shows only your name and picture.</span></label>
+ </div>
<div class="btn-row">
<div class="btn-row">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
@@ -396,7 +396,7 @@ router.get('/me', requireAuth, async (req, res) => {
@@ -396,7 +396,7 @@ router.get('/me', requireAuth, async (req, res) => {
// ── Update Profile ───────────────────────────────────────────
// ── Update Profile ───────────────────────────────────────────
router.put('/profile', requireAuth, async (req, res) => {
router.put('/profile', requireAuth, async (req, res) => {
const db = await getDb(req);
const db = await getDb(req);
- const { display_name, bio, avatar_url, email, profile_color } = req.body;
+ const { display_name, bio, avatar_url, email, profile_color, profile_public } = req.body;
const updates = [];
const updates = [];
const params = [];
const params = [];
@@ -425,6 +425,8 @@ router.put('/profile', requireAuth, async (req, res) => {
@@ -425,6 +425,8 @@ router.put('/profile', requireAuth, async (req, res) => {
updates.push('email = ?'); params.push(next);
updates.push('email = ?'); params.push(next);
}
}
if (profile_color !== undefined) { updates.push('profile_color = ?'); params.push(profile_color); }
if (profile_color !== undefined) { updates.push('profile_color = ?'); params.push(profile_color); }
+ // openvibe.network/@you (server/profile/public.js): shown to everyone, or only your name and picture.
+ if (profile_public !== undefined) { updates.push('profile_public = ?'); params.push(profile_public ? 1 : 0); }
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
@@ -678,6 +678,11 @@ const avatarService = require('./profile/avatar').createAvatarService({ db, conf
@@ -678,6 +678,11 @@ const avatarService = require('./profile/avatar').createAvatarService({ db, conf
app.use('/api/profile/avatar', rateLimit({ windowMs: 60_000, max: 20 }), avatarService.api);
app.use('/api/profile/avatar', rateLimit({ windowMs: 60_000, max: 20 }), avatarService.api);
app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub);
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,
+// 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' });
+app.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);
+app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles.pageHandler);
// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).
// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).
// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.
// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.
new file mode 100644
new file mode 100644
@@ -0,0 +1,324 @@
@@ -0,0 +1,324 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// Public profiles (plan T21 step 3): one page per person on the Network, and the same data as JSON for every site.
+//
+// GET /@:username HTML: picture, name, bio, member since, what they wear, the items they own
+// GET /api/v1/profiles/:username JSON, public and CORS-open (server/public-cors.js): profile@1 below
+//
+// 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.
+//
+// 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
+// Profile card): the page then shows only their name and picture, is not indexed, and the JSON says private: true.
+// ═══════════════════════════════════════════════════════════════
+const express = require('express');
+const seo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
+const usernames = require('../identity/usernames');
+
+const esc = seo.esc;
+const SITE = 'https://openvibe.network';
+const INVENTORY_SITE = 'https://inventory.openvibe.network';
+const AUDIENCE = 'openvibe.inventory';
+const ITEMS_TTL_MS = 60_000;
+const KINDS_TTL_MS = 10 * 60_000;
+const TIMEOUT_MS = 4000;
+const MAX_ITEMS = 400; // two pages of Inventory's 200; the rest is one link away on OpenVibe.Inventory
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
+const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
+const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
+const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+
+const profileUrl = (username) => `${SITE}/@${encodeURIComponent(username)}`;
+const avatarUrl = (username, size = 160) => `${SITE}/avatar/${encodeURIComponent(username)}?s=${size}`;
+const itemUrl = (definitionId) => `${INVENTORY_SITE}/items/${encodeURIComponent(definitionId)}`;
+const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
+const rarityRank = (r) => Math.max(0, RARITY.indexOf(r));
+
+/** 'YYYY-MM-DD …' → 'October 2026' (null when the date is unusable). */
+function monthOf(ts) {
+ const m = /^(\d{4})-(\d{2})/.exec(String(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 } = {}) {
+ const base = String(inventoryUrl).replace(/\/+$/, '');
+ const itemsCache = new Map(); // subject → { at, value }
+ let kindsHit = null;
+ let lastWarn = '';
+
+ async function inventoryGet(path) {
+ const token = selfToken(AUDIENCE, ['inventory.item.read']);
+ const r = await fetchImpl(`${base}/api/v1${path}`, {
+ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ });
+ if (!r.ok) throw new Error(`answered ${r.status}`);
+ return await r.json();
+ }
+
+ async function kinds() {
+ if (kindsHit && now() - kindsHit.at < KINDS_TTL_MS) return kindsHit.value;
+ const out = await inventoryGet('/kinds');
+ const value = new Map((out.kinds || []).map((k) => [k.id, k]));
+ kindsHit = { at: now(), value };
+ return value;
+ }
+
+ /** 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 };
+ const hit = itemsCache.get(subject);
+ if (hit && now() - hit.at < ITEMS_TTL_MS) return hit.value;
+ let value;
+ try {
+ const [kindMap, worn] = await Promise.all([kinds(), inventoryGet(`/people/${subject}/equipped`)]);
+ const instances = [];
+ const definitions = {};
+ let cursor = null;
+ let more = false;
+ do {
+ const page = await inventoryGet(`/people/${subject}/items?limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
+ instances.push(...(page.instances || []));
+ Object.assign(definitions, page.definitions || {});
+ cursor = page.next_cursor || null;
+ more = !!cursor && instances.length >= MAX_ITEMS;
+ } while (cursor && instances.length < MAX_ITEMS);
+ const kindOf = (id) => kindMap.get(id) || { id, name: id };
+ const items = instances.filter((i) => definitions[i.definition_id]).map((i) => {
+ const d = definitions[i.definition_id];
+ return { instance_id: i.id, definition_id: d.id, name: d.name, rarity: RARITY_LABEL[d.rarity] ? d.rarity : 'common', kind: d.kind, kind_name: kindOf(d.kind).name, art: d.art || {}, acquired_at: i.acquired_at || null, url: itemUrl(d.id) };
+ });
+ const byInstance = new Map(items.map((i) => [i.instance_id, i]));
+ const showcase = [];
+ for (const [key, slot] of Object.entries((worn && worn.slots) || {})) {
+ const item = byInstance.get(slot.instance_id);
+ if (item) showcase.push({ slot: key.split(':')[1] || key, ...item });
+ }
+ const order = [...kindMap.keys()];
+ showcase.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) || a.name.localeCompare(b.name));
+ value = { items, showcase, total: items.length, more };
+ lastWarn = '';
+ } catch (err) {
+ const m = `OpenVibe.Inventory: ${err.message}`;
+ if (m !== lastWarn) { lastWarn = m; log.warn(`[Profiles] ${m}`); }
+ return { items: [], showcase: [], total: 0, more: false, unavailable: true };
+ }
+ itemsCache.set(subject, { at: now(), value });
+ if (itemsCache.size > 2000) itemsCache.delete(itemsCache.keys().next().value);
+ return value;
+ }
+
+ /** → { moved: current } | { user } | null */
+ async function find(name) {
+ const rec = await usernames.lookup(db, name);
+ if (!rec) return null;
+ if (rec.renamed) return { moved: rec.current };
+ const u = await db.prepare(`SELECT id, username, display_name, bio, profile_color, created_at, subject_id, profile_public,
+ is_anon, merged_into, deleted_at FROM users WHERE id = ?`).get(rec.network_id);
+ if (!u || Number(u.is_anon) || u.merged_into || u.deleted_at) return null;
+ return { user: u };
+ }
+
+ /** profile@1: what every site may show about a person. */
+ async function profileOf(u) {
+ const out = {
+ username: u.username,
+ display_name: u.display_name || u.username,
+ avatar_url: avatarUrl(u.username),
+ profile_url: profileUrl(u.username),
+ private: !Number(u.profile_public),
+ };
+ if (out.private) return out;
+ const inv = await itemsOf(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, {
+ bio: String(u.bio || '').slice(0, 500),
+ color: COLOR_RE.test(String(u.profile_color || '')) ? u.profile_color : null,
+ member_since: String(u.created_at || '').slice(0, 10) || null,
+ 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,
+ });
+ }
+
+ /** GET /@:username (server/index.js mounts it with its own rate limit). */
+ async function pageHandler(req, res, next) {
+ try {
+ const found = await find(req.params.username);
+ 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;
+ res.set('Content-Type', 'text/html; charset=utf-8');
+ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
+ res.send(renderPage(u, inv));
+ } catch (err) { next(err); }
+ }
+
+ const api = express.Router();
+ api.get('/:username', async (req, res, next) => {
+ try {
+ const found = await find(req.params.username);
+ res.set('Cache-Control', 'public, max-age=60');
+ if (!found) return res.status(404).json({ error: 'not_found' });
+ if (found.moved) return res.status(301).set('Location', `/api/v1/profiles/${encodeURIComponent(found.moved)}`).json({ moved_to: found.moved });
+ res.json({ profile: await profileOf(found.user) });
+ } catch (err) { next(err); }
+ });
+
+ return { pageHandler, api, find, profileOf, itemsOf };
+}
+
+// ── The page ───────────────────────────────────────────────────
+
+/** The item's picture: a Media image, or its emoji, on a square tinted by rarity. */
+function art(i, size = 'md') {
+ const a = i.art || {};
+ if (a.media_id && /^[A-Za-z0-9_-]{1,80}$/.test(String(a.media_id))) {
+ return `<span class="pf-art pf-art-${size} r-${i.rarity}"><img src="https://openvibe.media/o/${esc(a.media_id)}" alt="" loading="lazy" width="64" height="64"></span>`;
+ }
+ return `<span class="pf-art pf-art-${size} r-${i.rarity}" aria-hidden="true">${esc(String(a.emoji || '◆').slice(0, 8))}</span>`;
+}
+const rarityBadge = (r) => `<span class="pf-rarity r-${esc(r)}">${esc(RARITY_LABEL[r] || r)}</span>`;
+
+function showcaseHtml(inv, name) {
+ if (!inv.showcase.length) return '';
+ return `<section class="pf-section" aria-labelledby="pf-wearing"><h2 id="pf-wearing">Wearing</h2>
+<ul class="pf-showcase">${inv.showcase.map((i) => `<li class="pf-worn r-${esc(i.rarity)}"><a href="${esc(i.url)}">${art(i, 'lg')}<span class="pf-worn-body"><span class="pf-kind">${esc(i.kind_name)}</span><span class="pf-name">${esc(i.name)}</span>${rarityBadge(i.rarity)}</span></a></li>`).join('')}</ul>
+<p class="pf-note">What ${esc(name)} shows in chat, on stream overlays and here.</p></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>`;
+ if (!inv.items.length) return `<section class="pf-section" aria-labelledby="pf-items"><h2 id="pf-items">Items</h2><p class="pf-empty">${name} has no items yet. Items come from streaming, chatting and playing on OpenVibe sites. <a href="${INVENTORY_SITE}/items">See what there is</a>.</p></section>`;
+ const groups = new Map();
+ for (const i of inv.items) {
+ if (!groups.has(i.kind)) groups.set(i.kind, { name: i.kind_name, list: [] });
+ groups.get(i.kind).list.push(i);
+ }
+ const worn = new Set(inv.showcase.map((i) => i.instance_id));
+ const sorted = [...groups.values()].sort((a, b) => b.list.length - a.list.length || a.name.localeCompare(b.name));
+ const all = SUBJECT_RE.test(String(u.subject_id || '')) ? `${INVENTORY_SITE}/u/${u.subject_id}` : `${INVENTORY_SITE}/`;
+ return `<section class="pf-section" aria-labelledby="pf-items"><h2 id="pf-items">Items <span class="pf-count">${inv.more ? `${inv.total}+` : inv.total}</span></h2>
+${sorted.map((g) => {
+ g.list.sort((a, b) => rarityRank(b.rarity) - rarityRank(a.rarity) || a.name.localeCompare(b.name));
+ return `<h3 class="pf-kind-head">${esc(g.name)} <span class="pf-count">${g.list.length}</span></h3>
+<ul class="pf-grid">${g.list.map((i) => `<li class="pf-card r-${esc(i.rarity)}${worn.has(i.instance_id) ? ' is-worn' : ''}"><a href="${esc(i.url)}" title="${esc(`${i.name} · ${RARITY_LABEL[i.rarity]}`)}">${art(i)}<span class="pf-card-name">${esc(i.name)}</span>${rarityBadge(i.rarity)}${worn.has(i.instance_id) ? '<span class="pf-worn-tag">Worn</span>' : ''}</a></li>`).join('')}</ul>`;
+ }).join('\n')}
+<p class="pf-note"><a href="${esc(all)}">${inv.more ? 'Every item' : 'This inventory'} on OpenVibe.Inventory</a> · <a href="${INVENTORY_SITE}/items">Every item there is</a></p></section>`;
+}
+
+function renderPage(u, inv) {
+ const name = u.display_name || u.username;
+ const isPublic = !!inv;
+ const color = COLOR_RE.test(String(u.profile_color || '')) ? u.profile_color : '#3b82f6';
+ const since = monthOf(u.created_at);
+ const bio = isPublic ? String(u.bio || '').trim().slice(0, 500) : '';
+ const title = `${name} (@${u.username}) · OpenVibe`;
+ const description = !isPublic
+ ? `${name} on OpenVibe. This profile is private.`
+ : (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.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('') : '';
+ const ld = isPublic ? seo.jsonLdTag({
+ '@context': 'https://schema.org', '@type': 'ProfilePage', url: profileUrl(u.username), name: title,
+ ...(String(u.created_at || '').length >= 10 ? { dateCreated: String(u.created_at).slice(0, 10) } : {}),
+ mainEntity: { '@type': 'Person', name, alternateName: `@${u.username}`, image: avatarUrl(u.username, 256), url: profileUrl(u.username), ...(bio ? { description: bio } : {}) },
+ }) : '';
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(title)}</title>
+<meta name="description" content="${esc(description)}">
+<link rel="canonical" href="${esc(profileUrl(u.username))}">
+${isPublic ? '' : '<meta name="robots" content="noindex">'}
+<meta property="og:type" content="profile">
+<meta property="og:title" content="${esc(title)}">
+<meta property="og:description" content="${esc(description)}">
+<meta property="og:url" content="${esc(profileUrl(u.username))}">
+<meta property="og:image" content="${esc(avatarUrl(u.username, 256))}">
+<meta property="profile:username" content="${esc(u.username)}">
+<link rel="alternate" type="application/json" href="/api/v1/profiles/${esc(encodeURIComponent(u.username))}">
+${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}
+<meta name="color-scheme" content="dark light">
+<script src="/shared/theme-loader.js" defer></script>
+${ld}
+<style>
+*,*::before,*::after{box-sizing:border-box}
+:root{--bg-primary:#0a0f1c;--bg-secondary:#101828;--bg-card:#0f172a;--border:#1f2d47;--accent:#3b82f6;--text-primary:#e6edf7;--text-secondary:#96a7c2;--text-muted:#7386a3;
+--r-common:#94a3b8;--r-uncommon:#22c55e;--r-rare:#3b82f6;--r-epic:#a855f7;--r-legendary:#f59e0b}
+html,body{margin:0;background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
+a{color:var(--accent)}
+.pf{max-width:1040px;margin:0 auto;padding:20px 16px 48px}
+.pf-hero{position:relative;border:1px solid var(--border);border-radius:18px;overflow:hidden;background:var(--bg-secondary)}
+.pf-banner{height:96px;background:linear-gradient(120deg,color-mix(in srgb,var(--pf-color) 70%,transparent),color-mix(in srgb,var(--pf-color) 18%,transparent) 60%,transparent),var(--bg-card)}
+.pf-head{display:flex;gap:20px;align-items:flex-end;padding:0 24px 20px;margin-top:-52px;flex-wrap:wrap}
+.pf-avatar{width:112px;height:112px;border-radius:50%;object-fit:cover;border:4px solid var(--bg-secondary);box-shadow:0 0 0 2px var(--pf-color);background:var(--bg-card);flex:none}
+.pf-who{flex:1;min-width:200px;padding-top:56px}
+.pf-who h1{margin:0;font-size:clamp(1.5rem,4vw,2.1rem);line-height:1.15;overflow-wrap:anywhere}
+.pf-handle{margin:.2em 0 0;color:var(--text-secondary)}
+.pf-bio{margin:0;padding:0 24px 18px;color:var(--text-primary);line-height:1.6;white-space:pre-line;overflow-wrap:anywhere;max-width:72ch}
+.pf-stats{list-style:none;display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0 24px 22px}
+.pf-stats li{display:flex;flex-direction:column;gap:2px;padding:8px 14px;border:1px solid var(--border);border-radius:12px;background:var(--bg-card);min-width:110px}
+.pf-stat-k{font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)}
+.pf-stat-v{font-weight:700}
+.pf-private{margin:0;padding:0 24px 24px;color:var(--text-secondary)}
+.pf-section{margin-top:28px}
+.pf-section h2{font-size:1.15rem;margin:0 0 12px;display:flex;align-items:center;gap:8px}
+.pf-kind-head{font-size:.85rem;text-transform:uppercase;letter-spacing:.06em;color:var(--text-secondary);margin:20px 0 10px;display:flex;align-items:center;gap:8px}
+.pf-count{font-size:.75rem;font-weight:700;padding:.1rem .5rem;border-radius:999px;background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--text-primary);letter-spacing:0;text-transform:none}
+.pf-showcase{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
+.pf-worn a{display:flex;gap:14px;align-items:center;padding:14px;border-radius:14px;text-decoration:none;color:inherit;border:1px solid color-mix(in srgb,var(--rc) 45%,var(--border));background:linear-gradient(135deg,color-mix(in srgb,var(--rc) 14%,var(--bg-secondary)),var(--bg-secondary))}
+.pf-worn a:hover,.pf-worn a:focus-visible{border-color:var(--rc)}
+.pf-worn-body{display:flex;flex-direction:column;gap:3px;min-width:0}
+.pf-kind{font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)}
+.pf-name{font-weight:700;overflow-wrap:anywhere}
+.pf-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:10px}
+.pf-card a{position:relative;display:flex;flex-direction:column;align-items:center;gap:6px;padding:12px 10px;border-radius:12px;border:1px solid var(--border);border-bottom:3px solid var(--rc);background:var(--bg-secondary);text-decoration:none;color:inherit;text-align:center;height:100%}
+.pf-card a:hover,.pf-card a:focus-visible{border-color:var(--rc);background:color-mix(in srgb,var(--rc) 8%,var(--bg-secondary))}
+.pf-card-name{font-size:.85rem;font-weight:600;overflow-wrap:anywhere}
+.pf-worn-tag{position:absolute;top:6px;right:6px;font-size:.62rem;font-weight:700;padding:.05rem .4rem;border-radius:999px;background:var(--accent);color:var(--on-accent,#fff)}
+.pf-art{display:inline-flex;align-items:center;justify-content:center;border-radius:12px;background:radial-gradient(circle at 50% 35%,color-mix(in srgb,var(--rc) 30%,transparent),color-mix(in srgb,var(--rc) 8%,var(--bg-card)));flex:none;overflow:hidden}
+.pf-art-md{width:56px;height:56px;font-size:28px}.pf-art-lg{width:68px;height:68px;font-size:34px}
+.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-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-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))}}
+</style>
+</head>
+<body>
+<div id="navbar-mount"></div>
+${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Inventory', href: `${INVENTORY_SITE}/` }] })}
+<main class="pf" id="main" style="--pf-color:${esc(color)}">
+<section class="pf-hero" aria-label="Profile">
+<div class="pf-banner" aria-hidden="true"></div>
+<div class="pf-head">
+<img class="pf-avatar" src="${esc(avatarUrl(u.username))}" alt="" width="112" height="112">
+<div class="pf-who"><h1>${esc(name)}</h1><p class="pf-handle">@${esc(u.username)}</p></div>
+</div>
+${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) : ''}
+</main>
+${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
+<script src="/shared/navbar.js" defer></script>
+<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
+</body>
+</html>`;
+}
+
+module.exports = { createPublicProfiles, renderPage, monthOf, SUBJECT_RE };
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
* GET /.well-known/openvibe
* GET /.well-known/openvibe
* GET /api/v1/registry and /api/v1/registry/<...>
* GET /api/v1/registry and /api/v1/registry/<...>
* GET /contracts/<domain>/<name>.v<N>.json (and /contracts/events/payloads/<event_type>.v<N>.json)
* GET /contracts/<domain>/<name>.v<N>.json (and /contracts/events/payloads/<event_type>.v<N>.json)
+ * GET /api/v1/profiles/<username> a person's public profile (server/profile/public.js)
*
*
* These answer every origin, preflight included, with `Access-Control-Allow-Origin: *` and no
* These answer every origin, preflight included, with `Access-Control-Allow-Origin: *` and no
* credentials (cookies are never needed and never sent). Every other route keeps the first-party
* credentials (cookies are never needed and never sent). Every other route keeps the first-party
@@ -26,6 +27,7 @@ function isPublicDiscoveryPath(p) {
@@ -26,6 +27,7 @@ function isPublicDiscoveryPath(p) {
const path = String(p || '');
const path = String(p || '');
if (path === '/.well-known/openvibe') return true;
if (path === '/.well-known/openvibe') return true;
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
+ if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every site
if (CONTRACT_RE.test(path)) return true;
if (CONTRACT_RE.test(path)) return true;
if (path === '/api/v1/registry' || path === '/api/v1/registry/') return true;
if (path === '/api/v1/registry' || path === '/api/v1/registry/') return true;
if (!path.startsWith('/api/v1/registry/')) return false;
if (!path.startsWith('/api/v1/registry/')) return false;
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
+'use strict';
+// Public profiles (server/profile/public.js, plan T21 step 3): openvibe.network/@<username> and
+// GET /api/v1/profiles/:username, with the items from a stand-in OpenVibe.Inventory read with the Network's own
+// service token. A public profile shows the picture, name, escaped bio, member since, what the person wears and their
+// items by kind; a hidden one only the name and picture (noindex); an anonymous or deleted account is a 404; an old
+// username answers 301; Inventory down still answers the profile; a second view within a minute reads nothing again.
+// node test/public-profile.test.js
+const assert = require('assert');
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const { createPublicProfiles, monthOf } = require('../server/profile/public');
+const publicCors = require('../server/public-cors');
+
+const A = 'usr_01JZ0000000000000000000AAA';
+const KINDS = [
+ { id: 'live.name_effect', name: 'Name effect' }, { id: 'live.hat', name: 'Hat' }, { id: 'live.particle', name: 'Particle' },
+];
+const DEFS = {
+ itd_01JZ00000000000000000001R1: { id: 'itd_01JZ00000000000000000001R1', kind: 'live.hat', name: 'Royal Crown', rarity: 'legendary', art: { emoji: '👑' } },
+ itd_01JZ00000000000000000001R2: { id: 'itd_01JZ00000000000000000001R2', kind: 'live.name_effect', name: 'Rainbow', rarity: 'epic', art: { emoji: '🌈' } },
+ itd_01JZ00000000000000000001R3: { id: 'itd_01JZ00000000000000000001R3', kind: 'live.particle', name: 'Sparkles <b>', rarity: 'common', art: { emoji: '✨' } },
+};
+const INSTANCES = [
+ { id: 'inv_01JZ0000000000000000000001', definition_id: 'itd_01JZ00000000000000000001R1', state: 'owned', acquired_at: '2026-10-02T00:00:00Z' },
+ { id: 'inv_01JZ0000000000000000000002', definition_id: 'itd_01JZ00000000000000000001R2', state: 'owned', acquired_at: '2026-10-03T00:00:00Z' },
+ { id: 'inv_01JZ0000000000000000000003', definition_id: 'itd_01JZ00000000000000000001R3', state: 'owned', acquired_at: '2026-10-04T00:00:00Z' },
+];
+
+(async () => {
+ assert.strictEqual(monthOf('2026-10-01 10:00:00'), 'October 2026');
+ assert.strictEqual(monthOf('bad'), null);
+ for (const p of ['/api/v1/profiles/Ana', '/api/v1/profiles/some_one_42']) assert.ok(publicCors.isPublicDiscoveryPath(p), `${p} is readable from any origin`);
+ for (const p of ['/api/v1/profiles', '/api/v1/profiles/', '/api/v1/profiles/a/b', '/api/v1/profiles/a.b', '/api/v1/profiles/%2e%2e']) assert.ok(!publicCors.isPublicDiscoveryPath(p), `${p} is not`);
+
+ // The route is mounted before the 404 page, and the column comes from a migration.
+ const index = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');
+ assert.ok(index.indexOf("app.get('/@:username'") > 0 && index.indexOf("app.get('/@:username'") < index.indexOf("require('./not-found').notFound"), '/@:username answers before the 404 page');
+ assert.match(fs.readFileSync(path.join(__dirname, '..', 'migrations', '0022_public_profiles.sql'), 'utf8'), /^-- phase: expand$/m);
+
+ const db = getDb();
+ await db.prepare(`INSERT INTO users (username, password_hash, display_name, bio, profile_color, created_at, subject_id, profile_public, is_anon, deleted_at) VALUES
+ ('Ana', 'x', 'Ana', 'I stream <script>alert(1)</script> & paint', '#ff0066', '2026-10-01 10:00:00', '${A}', 1, 0, NULL),
+ ('hidden', 'x', 'Hidden', 'secret bio', '#00ff00', '2026-09-01 10:00:00', 'usr_01JZ0000000000000000000BBB', 0, 0, NULL),
+ ('ghost', 'x', 'Ghost', '', NULL, '2026-09-01 10:00:00', NULL, 1, 1, NULL),
+ ('gone', 'x', 'Gone', '', NULL, '2026-09-01 10:00:00', NULL, 1, 0, '2026-10-05 00:00:00')`).run();
+ const ana = await db.prepare("SELECT id FROM users WHERE username = 'Ana'").get();
+ await db.prepare("INSERT INTO username_history (user_id, old_username, new_username) VALUES (?, 'oldana', 'Ana')").run(ana.id);
+ assert.strictEqual(Number((await db.prepare("SELECT profile_public FROM users WHERE username = 'Ana'").get()).profile_public), 1);
+
+ // A stand-in OpenVibe.Inventory.
+ const calls = [];
+ let down = false;
+ const inv = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ calls.push({ path: u.pathname, auth: req.headers.authorization || '' });
+ const json = (s, o) => { res.writeHead(s, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
+ if (down) return json(503, { code: 'down' });
+ 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 } } });
+ if (u.pathname === `/api/v1/people/${A}/items`) return json(200, { subject: A, instances: INSTANCES, definitions: DEFS, next_cursor: null });
+ 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 app = express();
+ app.get('/@:username', profiles.pageHandler);
+ app.use('/api/v1/profiles', profiles.api);
+ app.use((req, res) => res.status(404).send('not found'));
+ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
+ const b = `http://127.0.0.1:${srv.address().port}`;
+ const get = async (p) => { const r = await fetch(b + p, { redirect: 'manual' }); return { status: r.status, headers: r.headers, text: await r.text() }; };
+
+ try {
+ // A public profile.
+ let r = await get('/@ana');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('<h1>Ana</h1>') && r.text.includes('@Ana'));
+ assert.ok(!r.text.includes('<script>alert(1)</script>') && r.text.includes('I stream <script>alert(1)</script> & paint'), 'the bio is escaped');
+ assert.ok(r.text.includes('Sparkles <b>') && !r.text.includes('Sparkles <b>'), 'item names are escaped');
+ assert.ok(r.text.includes('id="pf-wearing"') && /pf-worn r-legendary[\s\S]*Royal Crown/.test(r.text), 'the crown is in the showcase');
+ assert.ok(r.text.includes('Member since') && r.text.includes('October 2026'));
+ assert.ok(/<span class="pf-stat-k">Rarest<\/span><span class="pf-stat-v">Legendary<\/span>/.test(r.text));
+ assert.ok(r.text.includes('<span class="pf-count">3</span>'), 'three items');
+ assert.ok(r.text.includes('"@type":"ProfilePage"') && r.text.includes('<link rel="canonical" href="https://openvibe.network/@Ana">'));
+ 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');
+
+ // Cached for a minute: a second view reads nothing.
+ const n = calls.length;
+ await get('/@Ana');
+ assert.strictEqual(calls.length, n, 'a second view within 60 s is cache');
+
+ // The JSON every site reads.
+ r = await get('/api/v1/profiles/Ana');
+ assert.strictEqual(r.status, 200);
+ const p = JSON.parse(r.text).profile;
+ assert.deepStrictEqual([p.username, p.private, p.color, p.member_since, p.items.count, p.showcase.map((s) => s.name)], ['Ana', false, '#ff0066', '2026-10-01', 3, ['Royal Crown']]);
+ 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']);
+
+ // A hidden profile: name and picture, nothing else, not indexed.
+ r = await get('/@hidden');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('This profile is private.') && r.text.includes('name="robots" content="noindex"'));
+ assert.ok(!r.text.includes('secret bio') && !r.text.includes('Member since') && !r.text.includes('class="pf-grid"'));
+ r = await get('/api/v1/profiles/hidden');
+ assert.deepStrictEqual(JSON.parse(r.text).profile, { username: 'hidden', display_name: 'Hidden', avatar_url: 'https://openvibe.network/avatar/hidden?s=160', profile_url: 'https://openvibe.network/@hidden', private: true });
+
+ // No profile: anonymous, deleted, unknown.
+ for (const name of ['ghost', 'gone', 'nobody', 'x']) {
+ assert.strictEqual((await get(`/@${name}`)).status, 404, `/@${name}`);
+ assert.strictEqual((await get(`/api/v1/profiles/${name}`)).status, 404, `api ${name}`);
+ }
+ // An old name: 301 to the current one.
+ r = await get('/@oldana');
+ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, '/@Ana']);
+ r = await get('/api/v1/profiles/oldana');
+ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, '/api/v1/profiles/Ana']);
+
+ // Inventory down (and the cache expired): the profile still answers.
+ down = true;
+ clock += 61_000;
+ r = await get('/@Ana');
+ assert.strictEqual(r.status, 200);
+ 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);
+ console.log('public profiles: all checks passed');
+ } finally {
+ srv.close();
+ inv.close();
+ }
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -123,6 +123,7 @@ const REVIEWED = {
@@ -123,6 +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/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'],
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');