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)

OP OpenVibers committed 9 Oct 2026 · d0b993fb71 · 12 files, +497 −3

Changes

ddiff --git a/README.md b/README.md
iindex 0522b70b..f15015a7 100644
---- a/README.md
++++ b/README.md
@@@ -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).
ddiff --git a/migrations/0022_public_profiles.sql b/migrations/0022_public_profiles.sql
nnew file mode 100644
iindex 00000000..fa636ed7
---- /dev/null
++++ b/migrations/0022_public_profiles.sql
@@@ -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;
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex 347877f0..3757f945 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -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);
ddiff --git a/public/my.html b/public/my.html
iindex 2b5a450a..117a6f1b 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -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>
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex c4b03466..8dadd045 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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' });
ddiff --git a/server/index.js b/server/index.js
iindex c1dc7ae9..8601f15d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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('/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.
ddiff --git a/server/profile/public.js b/server/profile/public.js
nnew file mode 100644
iindex 00000000..5d422e69
---- /dev/null
++++ b/server/profile/public.js
@@@ -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 };
ddiff --git a/server/public-cors.js b/server/public-cors.js
iindex b6ef520e..60d5e764 100644
---- a/server/public-cors.js
++++ b/server/public-cors.js
@@@ -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 site
if (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;
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 280028e0..55dead69 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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.
ddiff --git a/test/public-profile.test.js b/test/public-profile.test.js
nnew file mode 100644
iindex 00000000..4e7912c4
---- /dev/null
++++ b/test/public-profile.test.js
@@@ -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 &lt;script&gt;alert(1)&lt;/script&gt; &amp; paint'), 'the bio is escaped');
++ assert.ok(r.text.includes('Sparkles &lt;b&gt;') && !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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex ee40db3d..b492fdbc 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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'],
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 748efe7b..7efacda0 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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 twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');