openvibers/openvibe.network Commits

Add public person page settings (#676)

OP OpenVibers committed 5 days ago · 20c3166463 · 16 files, +382 −42

* Add public person page settings

* Complete public person pages

* Polish public person page themes

* Update profile migration and fetch guard tests

* openvibe.my/@handle: a link-in-bio page; videos link to their watch pages

Opus's design pass: one centred column (avatar, name, handle, pronouns as a chip, bio), the person's links as
full-width buttons a thumb can hit, then On OpenVibe with Live now marked in red. A video links to
openvibe.video/watch?v=<id> (its watch_id), or /v/<number>, which 301s there; /vod/<id> was not a page.

Changes

ddiff --git a/migrations/0052_public_person_pages.sql b/migrations/0052_public_person_pages.sql
nnew file mode 100644
iindex 00000000..f6d3f9c0
---- /dev/null
++++ b/migrations/0052_public_person_pages.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- Public person pages on openvibe.my. These settings belong to the account, not a client app.
++ALTER TABLE users ADD COLUMN IF NOT EXISTS public_page_bio text NOT NULL DEFAULT '';
++ALTER TABLE users ADD COLUMN IF NOT EXISTS public_page_pronouns text NOT NULL DEFAULT '';
++ALTER TABLE users ADD COLUMN IF NOT EXISTS public_page_links jsonb NOT NULL DEFAULT '[]'::jsonb;
++ALTER TABLE users ADD COLUMN IF NOT EXISTS public_page_places jsonb NOT NULL DEFAULT '{"live":true,"media":true,"space":true,"community":true}'::jsonb;
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex a93c60f6..3f461ef0 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -213,6 +213,20 @@ function renderProfile(user) {
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; }
++ const pageBio = document.getElementById('public-page-bio');
++ if (pageBio) {
++ const unavailable = !!user.is_anon;
++ document.getElementById('public-page-pronouns').value = user.public_page_pronouns || '';
++ pageBio.value = user.public_page_bio || '';
++ let links = []; try { links = typeof user.public_page_links === 'string' ? JSON.parse(user.public_page_links) : (user.public_page_links || []); } catch {}
++ document.getElementById('public-page-links').value = (Array.isArray(links) ? links : []).map(x => `${x.label} | ${x.url}`).join('\n');
++ let places = {}; try { places = typeof user.public_page_places === 'string' ? JSON.parse(user.public_page_places) : (user.public_page_places || {}); } catch {}
++ for (const key of ['live', 'media', 'space', 'community']) document.getElementById(`public-place-${key}`).checked = places[key] !== false;
++ const pageUrl = document.getElementById('public-page-url');
++ if (user.username && !user.is_anon) { pageUrl.href = `https://openvibe.my/@${encodeURIComponent(user.username)}`; pageUrl.textContent = `openvibe.my/@${user.username}`; }
++ for (const id of ['public-page-pronouns', 'public-page-bio', 'public-page-links', 'public-place-live', 'public-place-media', 'public-place-space', 'public-place-community', 'public-page-preview-toggle', 'public-page-save']) document.getElementById(id).disabled = unavailable;
++ if (unavailable) document.getElementById('public-page-msg').textContent = 'Sign in to edit your public page.';
++ }
if (!user.is_anon) {
document.getElementById('pf-bio').disabled = false;
document.getElementById('pf-email').disabled = false;
@@@ -308,6 +322,34 @@ document.getElementById('profile-form').addEventListener('submit', async (e) =>
} catch (err) { alert('Error: ' + err.message); }
});
++// Public-page editor: browser preview uses text nodes, and the server repeats every validation.
++(() => {
++ const save = document.getElementById('public-page-save');
++ if (!save) return;
++ const preview = () => {
++ const box = document.getElementById('public-page-preview'); box.hidden = false;
++ box.replaceChildren();
++ const h = document.createElement('h4'); h.textContent = currentUser?.display_name || currentUser?.username || 'Your name'; box.append(h);
++ const pronouns = document.getElementById('public-page-pronouns').value;
++ if (pronouns) { const byline = document.createElement('p'); byline.textContent = pronouns; box.append(byline); }
++ const bio = document.createElement('p'); bio.textContent = document.getElementById('public-page-bio').value; box.append(bio);
++ for (const row of document.getElementById('public-page-links').value.split('\n').filter(Boolean)) { const a = document.createElement('p'); a.textContent = row; box.append(a); }
++ };
++ document.getElementById('public-page-preview-toggle').addEventListener('click', preview);
++ save.addEventListener('click', async () => {
++ const msg = document.getElementById('public-page-msg');
++ const links = document.getElementById('public-page-links').value.split('\n').map(x => x.trim()).filter(Boolean).map(line => { const i = line.indexOf('|'); return { label: i < 0 ? '' : line.slice(0, i).trim(), url: i < 0 ? '' : line.slice(i + 1).trim() }; });
++ if (links.length > 8) { msg.textContent = 'Add up to 8 links.'; return; }
++ const places = Object.fromEntries(['live', 'media', 'space', 'community'].map(key => [key, document.getElementById(`public-place-${key}`).checked]));
++ save.disabled = true; msg.textContent = 'Saving…';
++ try {
++ const data = await apiFetch('/api/auth/profile', { method: 'PUT', body: JSON.stringify({ public_page_bio: document.getElementById('public-page-bio').value, public_page_pronouns: document.getElementById('public-page-pronouns').value, public_page_links: links, public_page_places: places }) });
++ currentUser = data.user; msg.textContent = 'Your public page is saved.'; preview();
++ } catch (err) { msg.textContent = err.message || 'Could not save your public page.'; }
++ save.disabled = false;
++ });
++})();
++
// Accounts
function loadAccounts() {
const accounts = JSON.parse(localStorage.getItem('openvibe_accounts') || '[]');
ddiff --git a/public/my.html b/public/my.html
iindex 5809aa44..9cfa0a67 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -200,6 +200,16 @@
</div>
</form>
</div>
++ <div class="card" id="public-page-card">
++ <h3><span class="icon"><i class="fa-solid fa-address-card"></i></span> Your public page</h3>
++ <p class="muted">A link-in-bio page at <a id="public-page-url" href="https://openvibe.my/" target="_blank" rel="noopener">openvibe.my/@you</a>. Only information already public while signed out can appear here.</p>
++ <label for="public-page-pronouns">Pronouns <small>(40 characters)</small></label><input id="public-page-pronouns" maxlength="40" autocomplete="off">
++ <label for="public-page-bio">Bio <small>(280 characters)</small></label><textarea id="public-page-bio" maxlength="280" rows="3"></textarea>
++ <label for="public-page-links">Links <small>(up to 8; one per line: label | https://…)</small></label><textarea id="public-page-links" rows="5" placeholder="My website | https://example.com"></textarea>
++ <fieldset><legend>Places to show</legend><label><input type="checkbox" id="public-place-live" checked> Live</label> <label><input type="checkbox" id="public-place-media" checked> Videos</label> <label><input type="checkbox" id="public-place-space" checked> Sites</label> <label><input type="checkbox" id="public-place-community" checked> Community</label></fieldset>
++ <div class="btn-row"><button type="button" class="btn" id="public-page-preview-toggle">Preview</button><button type="button" class="btn btn-primary" id="public-page-save">Save public page</button></div>
++ <div id="public-page-preview" hidden aria-live="polite"></div><div id="public-page-msg" role="status"></div>
++ </div>
</div>
<!-- Themes Section -->
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 41c51195..34d86cc6 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -408,7 +408,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, profile_public } = req.body;
++ const { display_name, bio, avatar_url, email, profile_color, profile_public, public_page_bio, public_page_pronouns, public_page_links, public_page_places } = req.body;
const updates = [];
const params = [];
@@@ -439,9 +439,40 @@ router.put('/profile', requireAuth, async (req, res) => {
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 (public_page_bio !== undefined) {
++ const text = String(public_page_bio || '').trim();
++ if (text.length > 280) return res.status(400).json({ error: 'Your public page bio can be up to 280 characters' });
++ updates.push('public_page_bio = ?'); params.push(text);
++ }
++ if (public_page_pronouns !== undefined) {
++ const text = String(public_page_pronouns || '').trim();
++ if (text.length > 40) return res.status(400).json({ error: 'Pronouns can be up to 40 characters' });
++ updates.push('public_page_pronouns = ?'); params.push(text);
++ }
++ if (public_page_links !== undefined) {
++ if (!Array.isArray(public_page_links) || public_page_links.length > 8) return res.status(400).json({ error: 'Add up to 8 links' });
++ const links = [];
++ for (const entry of public_page_links) {
++ const label = String(entry?.label || '').trim();
++ const url = String(entry?.url || '').trim();
++ let parsed;
++ try { parsed = new URL(url); } catch { return res.status(400).json({ error: 'Each link needs a valid HTTPS address' }); }
++ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || !label || label.length > 40 || url.length > 2048) return res.status(400).json({ error: 'Each link needs a label and a valid HTTPS address' });
++ links.push({ label, url: parsed.href });
++ }
++ updates.push('public_page_links = ?'); params.push(JSON.stringify(links));
++ }
++ if (public_page_places !== undefined) {
++ if (!public_page_places || typeof public_page_places !== 'object' || Array.isArray(public_page_places)) return res.status(400).json({ error: 'Choose which public places to show' });
++ const places = Object.fromEntries(['live', 'media', 'space', 'community'].map((key) => [key, public_page_places[key] !== false]));
++ updates.push('public_page_places = ?'); params.push(JSON.stringify(places));
++ }
if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' });
++ const publicPageTouched = public_page_bio !== undefined || public_page_pronouns !== undefined || public_page_links !== undefined || public_page_places !== undefined || profile_public !== undefined;
++ const beforePage = publicPageTouched ? await db.prepare('SELECT username, profile_public, public_page_bio, public_page_pronouns, public_page_links, public_page_places FROM users WHERE id = ?').get(req.user.id) : null;
++
updates.push('updated_at = ov_now()');
params.push(req.user.id);
try {
@@@ -454,6 +485,21 @@ router.put('/profile', requireAuth, async (req, res) => {
if (emailChanged) { try { await require('./email-verify').onEmailChanged(req, req.user.id); } catch { /* */ } }
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
++ if (beforePage && publicPageTouched) {
++ const json = (value) => { try { return typeof value === 'string' ? JSON.parse(value) : value; } catch { return null; } };
++ const changed = String(beforePage.public_page_bio || '') !== String(user.public_page_bio || '')
++ || String(beforePage.public_page_pronouns || '') !== String(user.public_page_pronouns || '')
++ || JSON.stringify(json(beforePage.public_page_links) || []) !== JSON.stringify(json(user.public_page_links) || [])
++ || JSON.stringify(json(beforePage.public_page_places) || {}) !== JSON.stringify(json(user.public_page_places) || {})
++ || Number(beforePage.profile_public) !== Number(user.profile_public);
++ let links = json(user.public_page_links);
++ if (!Array.isArray(links)) links = [];
++ const indexable = Number(user.profile_public) && (!!String(user.public_page_bio || '').trim() || links.length > 0);
++ if (changed && indexable) {
++ const origin = String(getConfig(req).my?.baseUrl || 'https://openvibe.my').replace(/\/+$/, '');
++ req.app.locals.publicPageIndexNow?.pingSoon(`${origin}/@${encodeURIComponent(user.username)}`);
++ }
++ }
res.json({ user: sanitizeUser(user) });
});
ddiff --git a/server/connect/mcp.js b/server/connect/mcp.js
iindex 3b886c23..38d3712b 100644
---- a/server/connect/mcp.js
++++ b/server/connect/mcp.js
@@@ -18,6 +18,12 @@
* isError and a short line; nothing secret is ever in the answer.
*/
const { resolveCaller } = require('./auth');
++const PUBLIC_PERSON_TOOL = {
++ name: 'network.person_page.read',
++ description: 'Read a person’s public OpenVibe page by handle. Returns only fields and places visible while signed out.',
++ inputSchema: { type: 'object', properties: { handle: { type: 'string', minLength: 3, maxLength: 24, pattern: '^[A-Za-z0-9_]+$' } }, required: ['handle'], additionalProperties: false },
++ annotations: { title: 'Read a public person page', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
++};
const PROTOCOL_VERSION = '2025-06-18';
// The versions this server speaks. The client's own version is echoed when it is one of these, so a
@@@ -56,7 +62,7 @@ function toTools(actions) {
/**
* The handler for POST /mcp. → (req, res)
*/
--function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null, requestOptions = {} } = {}) {
++function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null, requestOptions = {}, publicPage = null } = {}) {
return async function mcpHandler(req, res) {
if (req.method !== 'POST') {
// The transport allows a server to refuse the server-to-client stream.
@@@ -91,12 +97,20 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null
if (method === 'ping') return ok(res, id, {});
if (method === 'tools/list') {
const actions = await gateway.listActions({ subject: who.subject, callerId: who.caller.id });
-- return ok(res, id, { tools: toTools(actions) });
++ return ok(res, id, { tools: [...toTools(actions), ...(publicPage ? [PUBLIC_PERSON_TOOL] : [])] });
}
if (method === 'tools/call') {
const name = String(params.name || '');
const args = params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
try {
++ if (name === PUBLIC_PERSON_TOOL.name && publicPage) {
++ const handle = String(args.handle || '');
++ if (!/^[A-Za-z0-9_]{3,24}$/.test(handle)) throw Object.assign(new Error('Use a valid OpenVibe handle.'), { status: 400 });
++ const out = await publicPage(handle);
++ if (!out) throw Object.assign(new Error('No public page found for that handle.'), { status: 404 });
++ const text = JSON.stringify(out, null, 2);
++ return ok(res, id, { content: [{ type: 'text', text }], structuredContent: out });
++ }
const out = await gateway.call({
subject: who.subject, callerId: who.caller.id, callerName: who.caller.name,
actionName: name, input: args, requestOptions,
@@@ -115,4 +129,4 @@ function createMcpHandler({ db, config, gateway, publicKey = null, issuer = null
};
}
--module.exports = { createMcpHandler, toTools, PROTOCOL_VERSION, SUPPORTED_VERSIONS, SERVER_INFO };
++module.exports = { createMcpHandler, toTools, PROTOCOL_VERSION, SUPPORTED_VERSIONS, SERVER_INFO, PUBLIC_PERSON_TOOL };
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 32be80d5..c2e6a1e9 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -498,6 +498,8 @@ function llmsTxt() {
// The JSON an agent came for is a main section, not something to skip (AGT-5 review).
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
++ { title: 'Public person page (JSON)', url: 'https://openvibe.network/api/v1/people/@handle/page', note: 'the signed-out profile at https://openvibe.my/@handle, including its public OpenVibe places' },
++ { title: 'OpenVibe Connect MCP', url: 'https://connect.openvibe.network/mcp', note: 'includes network.person_page.read for a public person page' },
{ title: 'Tool catalog (JSON)', url: 'https://openvibe.tools/api/catalog.json' },
{ title: 'Tool domains (JSON)', url: 'https://openvibe.network/api/domains' },
] },
ddiff --git a/server/index.js b/server/index.js
iindex d3b04070..b84ee29c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -405,6 +405,15 @@ const selfToken = require('./identity/self-token').createSelfTokens({ privateKey
app.locals.selfToken = selfToken;
app.locals.publicKey = publicKey;
app.locals.config = config;
++const publicProfiles = require('./profile/public').createPublicProfiles({
++ db, selfToken,
++ inventoryUrl: process.env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030',
++ questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980',
++ playUrl: process.env.OV_PLAY_INTERNAL_URL || 'http://127.0.0.1:5040',
++ liveUrl: config.services.live.internalUrl,
++ communityUrl: process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200',
++ spaceUrl: process.env.OV_SPACE_INTERNAL_URL || 'http://127.0.0.1:4940',
++});
// Shared counters on Valkey when VALKEY_URL is set (ADR-035, plan T2): every process and host counts one
// actor together. Unset — or unreachable — the limits fall back to this process's own counters (never 500).
@@@ -480,6 +489,12 @@ app.use('/api/v1/connect', require('./connect/routes').createConnectRoutes({ db,
// The MCP server: https://connect.openvibe.network/mcp (streamable HTTP). One JSON-RPC call per request.
app.post('/mcp', rateLimit({ windowMs: 60_000, max: 600 }), require('./connect/mcp').createMcpHandler({
db, config, gateway: connectGateway, publicKey, issuer: config.jwt.issuer,
++ publicPage: async (handle) => {
++ const found = await publicProfiles.find(handle);
++ if (!found) return null;
++ if (found.moved) return { moved_to: found.moved };
++ return await publicProfiles.pageOf(found.user);
++ },
}));
// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).
app.use('/api/v1/staff', require('./admin/staff-api').createStaffApi({ db, requireAuth, guard: require('./identity/principals').guard }));
@@@ -896,8 +911,8 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)
app.locals.avatarService = avatarService;
// Public profiles (plan T21 step 3): openvibe.network/@<username> and GET /api/v1/profiles/:username (CORS-open,
// server/public-cors.js), with the items from OpenVibe.Inventory (server/profile/public.js).
--const publicProfiles = require('./profile/public').createPublicProfiles({ db, selfToken, inventoryUrl: process.env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030', questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980', playUrl: process.env.OV_PLAY_INTERNAL_URL || 'http://127.0.0.1:5040' });
app.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);
++app.get('/api/v1/people/@:handle/page', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.pageApi);
app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles.pageHandler);
// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).
@@@ -1011,11 +1026,14 @@ app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control',
app.get('/llms-full.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(require('./home/render').llmsFullTxt({ baseUrl: config.baseUrl })));
// The crawl files the front door owes crawlers, built from openvibe-shared/seo (plan T11 lane D), ahead
// of express.static below so they answer the generated copy rather than public/.
--app.use(require('./seo/routes').createSeoRoutes({ release }));
++app.use(require('./seo/routes').createSeoRoutes({ release, db }));
// IndexNow key file (openvibe-shared/indexnow), from INDEXNOW_KEY; unset → off, nothing served. The
// public page set is the fixed seo/routes PAGES list, so no hook pings; the client stays for one that will.
const indexnow = require('openvibe-shared/indexnow').createIndexNow({ host: config.baseUrl, key: config.indexnow.key });
++const myIndexNow = require('openvibe-shared/indexnow').createIndexNow({ host: config.my.baseUrl, key: config.indexnow.key });
if (indexnow.enabled) app.use(indexnow.keyFile);
++if (myIndexNow.enabled) app.use(myIndexNow.keyFile);
++app.locals.publicPageIndexNow = myIndexNow;
// The wallet (MONEY.md D12) and the Vibes Pay sheet: their own pages, in the account pages' style. A guest is sent
// to sign in from the page, which then returns them to the same link.
ddiff --git a/server/profile/public.js b/server/profile/public.js
iindex c917961f..74e33bae 100644
---- a/server/profile/public.js
++++ b/server/profile/public.js
@@@ -25,6 +25,9 @@ const { siteForHost, fullName } = require('../frame/sites');
const esc = seo.esc;
const SITE = 'https://openvibe.network';
++const MY_SITE = 'https://openvibe.my';
++const SPACE_SITE = 'https://openvibe.space';
++const COMMUNITY_SITE = 'https://openvibe.community';
const INVENTORY_SITE = 'https://openvibe.my/inventory';
const QUEST_SITE = 'https://openvibe.quest';
const GAMES_ACHIEVEMENTS = 'https://openvibe.games/achievements#game-';
@@@ -33,6 +36,8 @@ const AUDIENCE = 'openvibe.inventory';
const ITEMS_TTL_MS = 60_000;
const KINDS_TTL_MS = 10 * 60_000;
const TIMEOUT_MS = 4000;
++const PUBLIC_PLACE_TIMEOUT_MS = 2000;
++const PUBLIC_PLACE_TTL_MS = 5 * 60_000;
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}$/;
@@@ -53,11 +58,15 @@ function monthOf(ts) {
return m && MONTHS[Number(m[2]) - 1] ? `${MONTHS[Number(m[2]) - 1]} ${m[1]}` : null;
}
--function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http://127.0.0.1:5030', questUrl = 'http://127.0.0.1:4980', playUrl = 'http://127.0.0.1:5040', fetchImpl = (...a) => fetch(...a), now = () => Date.now(), log = console } = {}) {
++function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http://127.0.0.1:5030', questUrl = 'http://127.0.0.1:4980', playUrl = 'http://127.0.0.1:5040', liveUrl = 'http://127.0.0.1:3000', communityUrl = 'http://127.0.0.1:4200', spaceUrl = 'http://127.0.0.1:4940', fetchImpl = (...a) => fetch(...a), now = () => Date.now(), log = console } = {}) {
const base = String(inventoryUrl).replace(/\/+$/, '');
const itemsCache = new Map(); // subject → { at, value }
const badgesCache = new Map(); // subject → { at, value }
const achievementsCache = new Map(); // subject → { at, value }
++ const placesCache = new Map(); // username → { at, value }
++ const liveBase = String(liveUrl).replace(/\/+$/, '');
++ const communityBase = String(communityUrl).replace(/\/+$/, '');
++ const spaceBase = String(spaceUrl).replace(/\/+$/, '');
let kindsHit = null;
let lastWarn = '';
@@@ -193,17 +202,99 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
return value;
}
++ async function publicJson(url) {
++ const r = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(PUBLIC_PLACE_TIMEOUT_MS) });
++ if (!r.ok) throw new Error(`answered ${r.status}`);
++ return await r.json();
++ }
++
++ function plainHtml(s) {
++ return String(s || '').replace(/<[^>]*>/g, '').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim();
++ }
++
++ async function livePlace(username) {
++ const pathName = encodeURIComponent(username);
++ const [state, channel] = await Promise.all([
++ publicJson(`${liveBase}/api/streams/channel/${pathName}/live`),
++ publicJson(`${liveBase}/api/streams/channel/${pathName}?vodLimit=3&vodOrderBy=newest&clipLimit=1`),
++ ]);
++ const ch = state.channel || channel.channel;
++ if (!ch || typeof ch.username !== 'string') return null;
++ const streams = Array.isArray(state.streams) ? state.streams : [];
++ return {
++ live: { title: ch.display_name || ch.username, username: ch.username, is_live: streams.length > 0, url: `https://openvibe.live/@${encodeURIComponent(ch.username)}` },
++ videos: (Array.isArray(channel.vods) ? channel.vods : []).filter((v) => v && v.visibility === 'public' && (v.id || v.vod_id) && (v.title || v.name)).slice(0, 3).map((v) => ({
++ // Video's watch page by its public id (Track U); /v/<number> 301s there for a recording without one yet.
++ title: String(v.title || v.name).slice(0, 100), url: /^[A-Za-z0-9_-]{11}$/.test(String(v.watch_id || ''))
++ ? `https://openvibe.video/watch?v=${v.watch_id}` : `https://openvibe.video/v/${encodeURIComponent(v.id || v.vod_id)}`,
++ })),
++ };
++ }
++
++ async function communityPlaces(username) {
++ const body = await publicJson(`${communityBase}/api/pastes/by-user/${encodeURIComponent(username)}?limit=3`);
++ return (Array.isArray(body.pastes) ? body.pastes : []).filter((p) => p && p.visibility === 'public' && typeof p.slug === 'string' && /^[A-Za-z0-9_-]{1,120}$/.test(p.slug) && typeof p.title === 'string' && p.title).slice(0, 3).map((p) => ({
++ title: String(p.title).slice(0, 100), url: `${COMMUNITY_SITE}/p/${encodeURIComponent(p.slug)}`,
++ }));
++ }
++
++ async function spacePlaces(username) {
++ const url = `${spaceBase}/spaces?q=${encodeURIComponent(username)}`;
++ const r = await fetchImpl(url, { headers: { Accept: 'text/html' }, signal: AbortSignal.timeout(PUBLIC_PLACE_TIMEOUT_MS) });
++ if (!r.ok) throw new Error(`answered ${r.status}`);
++ const html = await r.text();
++ const out = [];
++ for (const [, card] of html.matchAll(/<article class="sp-card">([\s\S]*?)<\/article>/g)) {
++ const owner = /<footer><span>by @([^<]+)<\/span>/.exec(card);
++ if (!owner || owner[1].toLowerCase() !== username.toLowerCase()) continue;
++ const href = /<a class="sp-card-main" href="\/spaces\/([a-z0-9][a-z0-9_-]{0,79})">/.exec(card);
++ const badge = /<span class="sp-card-badge[^\"]*">([^<]+)<\/span>/.exec(card);
++ const title = /<h3>([\s\S]*?)<\/h3>/.exec(card);
++ if (!href || !title || !badge || plainHtml(badge[1]) !== 'Published') continue;
++ out.push({ title: plainHtml(title[1]).slice(0, 100), url: `${SPACE_SITE}/spaces/${encodeURIComponent(href[1])}` });
++ }
++ return out.slice(0, 8);
++ }
++
++ async function placesOf(username, choices = {}) {
++ const hit = placesCache.get(username);
++ let all = hit && now() - hit.at < PUBLIC_PLACE_TTL_MS ? hit.value : null;
++ if (!all) {
++ const [live, community, spaces] = await Promise.all([
++ livePlace(username).catch(() => null), communityPlaces(username).catch(() => []), spacePlaces(username).catch(() => []),
++ ]);
++ all = { live: live?.live || null, videos: live?.videos || [], spaces: spaces || [], community: community || [] };
++ placesCache.set(username, { at: now(), value: all });
++ }
++ const value = {
++ ...(choices.live === false ? {} : (all.live ? { live: all.live } : {})),
++ ...(choices.media === false ? {} : { videos: all.videos }),
++ spaces: choices.space === false ? [] : all.spaces,
++ community: choices.community === false ? [] : all.community,
++ };
++ if (placesCache.size > 2000) placesCache.delete(placesCache.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,
++ const u = await db.prepare(`SELECT id, username, display_name, bio, public_page_bio, public_page_pronouns, public_page_links, public_page_places, 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 };
}
++ async function pageOf(u) {
++ let links = [], choices = { live: true, media: true, space: true, community: true };
++ try { links = typeof u.public_page_links === 'string' ? JSON.parse(u.public_page_links) : (u.public_page_links || []); } catch {}
++ try { choices = { ...choices, ...(typeof u.public_page_places === 'string' ? JSON.parse(u.public_page_places) : (u.public_page_places || {})) }; } catch {}
++ const places = Number(u.profile_public) ? await placesOf(u.username, choices) : { spaces: [], community: [], videos: [] };
++ return { username: u.username, display_name: u.display_name || u.username, pronouns: Number(u.profile_public) ? String(u.public_page_pronouns || '').slice(0, 40) : '', avatar_url: avatarUrl(u.username), profile_url: `${MY_SITE}/@${encodeURIComponent(u.username)}`, bio: Number(u.profile_public) ? String(u.public_page_bio || '').slice(0, 280) : '', links: Number(u.profile_public) && Array.isArray(links) ? links.slice(0, 8) : [], places };
++ }
++
/** profile@1: what every site may show about a person. */
async function profileOf(u) {
const out = {
@@@ -237,10 +328,12 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
if (!found) return next(); // the shared 404 page
if (found.moved) return res.redirect(301, `/@${encodeURIComponent(found.moved)}`);
const u = found.user;
-- const inv = Number(u.profile_public) ? await wearsOf(u.subject_id) : null;
++ if (String(req.params.username) !== String(u.username)) return res.redirect(301, `/@${encodeURIComponent(u.username)}`);
++ const [inv, page] = await Promise.all([Number(u.profile_public) ? wearsOf(u.subject_id) : null, pageOf(u)]);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', cache.htmlHeaders({ maxAge: 60 }));
-- res.send(renderPage(u, inv));
++ const origin = String(req.hostname || '').toLowerCase() === 'openvibe.my' ? 'https://openvibe.my' : SITE;
++ res.send(origin === MY_SITE ? renderPersonPage(u, page, origin) : renderPage(u, inv, origin, page.places));
} catch (err) { next(err); }
}
@@@ -255,7 +348,19 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
} catch (err) { next(err); }
});
-- return { pageHandler, api, find, profileOf, itemsOf, badgesOf, achievementsOf };
++ // Stable public-page JSON read. It intentionally contains only fields already visible while signed out.
++ const pageApi = async (req, res, next) => {
++ try {
++ const found = await find(req.params.handle);
++ 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/people/@${encodeURIComponent(found.moved)}/page`).json({ moved_to: found.moved });
++ if (req.params.handle !== found.user.username) return res.status(301).set('Location', `/api/v1/people/@${encodeURIComponent(found.user.username)}/page`).json({ moved_to: found.user.username });
++ res.json({ person: await pageOf(found.user) });
++ } catch (err) { next(err); }
++ };
++
++ return { pageHandler, pageApi, api, find, pageOf, profileOf, itemsOf, badgesOf, achievementsOf };
}
// ── The page ───────────────────────────────────────────────────
@@@ -270,6 +375,16 @@ function art(i, size = 'md') {
}
const rarityBadge = (r) => `<span class="pf-rarity r-${esc(r)}">${esc(RARITY_LABEL[r] || r)}</span>`;
++function publicPlacesHtml(places) {
++ const cards = [];
++ if (places.live) cards.push(`<li><a href="${esc(places.live.url)}"><strong>${esc(places.live.title)}</strong>${places.live.is_live ? '<span class="pf-live">Live now</span>' : '<span>Live channel</span>'}</a></li>`);
++ for (const video of places.videos || []) cards.push(`<li><a href="${esc(video.url)}"><strong>${esc(video.title)}</strong><span>Video</span></a></li>`);
++ for (const space of places.spaces || []) cards.push(`<li><a href="${esc(space.url)}"><strong>${esc(space.title)}</strong><span>Published site</span></a></li>`);
++ for (const paste of places.community || []) cards.push(`<li><a href="${esc(paste.url)}"><strong>${esc(paste.title)}</strong><span>Community paste</span></a></li>`);
++ if (!cards.length) return '';
++ return `<section class="pf-section" aria-labelledby="pf-places"><h2 id="pf-places">On OpenVibe</h2><ul class="pf-places">${cards.join('')}</ul></section>`;
++}
++
function showcaseHtml(inv, name) {
if (!inv.showcase.length) return '';
return `<section class="pf-section" aria-labelledby="pf-wearing"><h2 id="pf-wearing">Wearing</h2>
@@@ -324,7 +439,33 @@ ${sorted.map((g) => {
<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) {
++function renderPersonPage(u, page, origin = MY_SITE) {
++ const name = page.display_name || page.username;
++ const url = `${origin}/@${encodeURIComponent(page.username)}`;
++ const indexable = !!(page.bio || page.links.length);
++ const ld = seo.jsonLdTag({
++ '@context': 'https://schema.org', '@type': 'ProfilePage', url, name: `${name} (@${page.username}) · OpenVibe`,
++ mainEntity: { '@type': 'Person', name, alternateName: `@${page.username}`, image: page.avatar_url, url, ...(page.bio ? { description: page.bio } : {}) },
++ });
++ const links = page.links.map((l) => {
++ try { const parsed = new URL(l.url); return parsed.protocol === 'https:' && !parsed.username && !parsed.password ? `<li><a href="${esc(parsed.href)}" rel="me noopener noreferrer">${esc(l.label)}</a></li>` : ''; } catch { return ''; }
++ }).join('');
++ const places = publicPlacesHtml(page.places);
++ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
++<title>${esc(name)} (@${esc(page.username)}) · OpenVibe</title><meta name="description" content="${esc(page.bio || `${name} on OpenVibe.`)}">
++<link rel="canonical" href="${esc(url)}">${indexable ? '' : '<meta name="robots" content="noindex">'}
++<meta property="og:type" content="profile"><meta property="og:title" content="${esc(`${name} (@${page.username})`)}"><meta property="og:url" content="${esc(url)}"><meta property="og:image" content="${esc(page.avatar_url)}"><meta property="profile:username" content="${esc(page.username)}">
++${ld}<style>
++:root{color-scheme:light dark}*{box-sizing:border-box}body{margin:0;background:var(--bg-primary,#f5f6fa);color:var(--text-primary,#171923);font:16px/1.55 system-ui,sans-serif}.person-page{width:min(100% - 32px,600px);margin:clamp(24px,7vw,80px) auto}.person-card{padding:clamp(22px,5vw,40px);border:1px solid var(--border,#d8dbe4);border-radius:24px;background:var(--bg-card,var(--bg-secondary,#fff));box-shadow:0 18px 54px #0002}.person-head{display:flex;flex-direction:column;align-items:center;gap:14px;text-align:center}.person-avatar{width:104px;height:104px;border-radius:50%;object-fit:cover;background:var(--bg-secondary,#eee)}h1{font-size:clamp(1.7rem,5vw,2.5rem);line-height:1.1;margin:0;overflow-wrap:anywhere}.person-handle{margin:6px 0 0;color:var(--text-secondary,#5c6170)}.person-pronouns{display:inline-block;margin:8px 0 0;padding:2px 10px;border:1px solid var(--border,#d8dbe4);border-radius:999px;font-size:.85rem;color:var(--text-secondary,#5c6170)}.person-bio{margin:18px auto 0;max-width:46ch;text-align:center;white-space:pre-wrap;overflow-wrap:anywhere}.person-links{display:grid;gap:10px;list-style:none;margin:24px 0 0;padding:0}.person-links a{display:flex;min-height:52px;align-items:center;justify-content:center;padding:10px 18px;border:1px solid var(--border,#d8dbe4);border-radius:14px;background:var(--bg-secondary,#f6f7fa);color:inherit;font-weight:650;text-align:center;text-decoration:none;overflow-wrap:anywhere;transition:border-color .15s,transform .15s}.person-links a:hover{border-color:var(--accent,#6d5dfc)}.person-links a:active{transform:scale(.99)}.person-places{margin-top:30px}.person-places h2{margin:0 0 10px;font-size:.78rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary,#5c6170)}.pf-places{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(230px,100%),1fr));gap:12px;list-style:none;padding:0;margin:0}.pf-places li{min-width:0}.pf-places a{display:flex;flex-direction:column;justify-content:center;gap:5px;min-height:72px;padding:12px 16px;border:1px solid var(--border,#d8dbe4);border-radius:14px;background:var(--bg-secondary,#f6f7fa);color:inherit;text-decoration:none;overflow-wrap:anywhere}.pf-places a span{font-size:.82rem;color:var(--text-secondary,#5c6170)}.pf-places a .pf-live{display:inline-flex;align-items:center;gap:6px;color:var(--live-red,#e5484d);font-weight:700}.pf-live::before{content:'';width:8px;height:8px;border-radius:50%;background:currentColor}@media(max-width:520px){.person-avatar{width:88px;height:88px}}@media(prefers-color-scheme:dark){body{background:var(--bg-primary,#101218);color:var(--text-primary,#f5f6fa)}.person-card{border-color:var(--border,#30333d);background:var(--bg-card,var(--bg-secondary,#171a22))}.person-avatar{background:var(--bg-secondary,#222)}.person-handle,.person-pronouns,.pf-places a span{color:var(--text-secondary,#a8adba)}.person-links a,.pf-places a{border-color:var(--border,#30333d)}.person-links a,.pf-places a{background:var(--bg-secondary,#20232d)}}
++</style></head><body><div id="navbar-mount"></div><main class="person-page"><article class="person-card" itemscope itemtype="https://schema.org/Person">
++<div class="person-head"><img class="person-avatar" src="${esc(page.avatar_url)}" alt="" width="104" height="104" itemprop="image"><div><h1 itemprop="name">${esc(name)}</h1><p class="person-handle">@${esc(page.username)}</p>${page.pronouns ? `<p class="person-pronouns">${esc(page.pronouns)}</p>` : ''}</div></div>
++${page.bio ? `<p class="person-bio" itemprop="description">${esc(page.bio)}</p>` : ''}${links ? `<nav aria-label="${esc(name)}'s links"><ul class="person-links">${links}</ul></nav>` : ''}
++${places ? `<div class="person-places">${places}</div>` : ''}</article></main>${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
++<script src="/shared/navbar.js" defer></script><script src="/js/account-safety.js" defer></script><script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script></body></html>`;
++}
++
++function renderPage(u, inv, origin = SITE, places = { spaces: [], community: [], videos: [] }) {
++ const canonicalUrl = `${origin}/@${encodeURIComponent(u.username)}`;
const name = u.display_name || u.username;
const isPublic = !!inv;
// What they wear shows on the profile itself: the name effect on their name (items.css), the hat on the picture.
@@@ -338,7 +479,10 @@ function renderPage(u, inv) {
const badgeId = badge && /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String((badge.art || {}).media_id || '')) ? badge.art.media_id : null;
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 bio = isPublic ? String(u.public_page_bio || u.bio || '').trim().slice(0, 280) : '';
++ let publicLinks = [];
++ try { publicLinks = typeof u.public_page_links === 'string' ? JSON.parse(u.public_page_links) : (u.public_page_links || []); } catch {}
++ const indexable = isPublic && (!!String(u.public_page_bio || '').trim() || (Array.isArray(publicLinks) && publicLinks.length > 0));
const title = `${name} (@${u.username}) · OpenVibe`;
const description = !isPublic
? `${name} on OpenVibe. This profile is private.`
@@@ -351,9 +495,9 @@ function renderPage(u, inv) {
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,
++ '@context': 'https://schema.org', '@type': 'ProfilePage', url: canonicalUrl, 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 } : {}) },
++ mainEntity: { '@type': 'Person', name, alternateName: `@${u.username}`, image: avatarUrl(u.username, 256), url: canonicalUrl, ...(bio ? { description: bio } : {}) },
}) : '';
return `<!DOCTYPE html>
<html lang="en">
@@@ -362,12 +506,12 @@ function renderPage(u, inv) {
<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">'}
++<link rel="canonical" href="${esc(canonicalUrl)}">
++${indexable ? '' : '<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:url" content="${esc(canonicalUrl)}">
<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))}">
@@@ -431,6 +575,8 @@ a{color:var(--accent)}
.pf-ach li{overflow-wrap:anywhere}.pf-ach small{color:var(--text-muted);font-size:.75rem}
.pf-empty{color:var(--text-secondary);padding:18px;border:1px dashed var(--border);border-radius:12px;margin:0}
.pf-note{color:var(--text-secondary);font-size:.85rem;margin:12px 0 0}
++.pf-links{display:flex;flex-wrap:wrap;gap:10px;list-style:none;margin:12px 0;padding:0}.pf-links a{display:inline-flex;min-height:44px;align-items:center;padding:8px 14px;border:1px solid var(--border);border-radius:999px;color:var(--text-primary);text-decoration:none}
++.pf-places{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(230px,100%),1fr));gap:12px;list-style:none;padding:0;margin:0}.pf-places li{min-width:0}.pf-places a{display:flex;flex-direction:column;justify-content:center;gap:5px;min-height:72px;padding:12px 16px;border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary);color:var(--text-primary);text-decoration:none;overflow-wrap:anywhere}.pf-places a span{font-size:.82rem;color:var(--text-secondary)}
@media (max-width:560px){.pf-head{padding:0 16px 16px;gap:14px}.pf-avatar{width:92px;height:92px}.pf-hat{font-size:32px;top:-18px}.pf-who{padding-top:0;min-width:100%}.pf-bio,.pf-stats,.pf-private{padding-left:16px;padding-right:16px}.pf-grid{grid-template-columns:repeat(auto-fill,minmax(112px,1fr))}.pf-badge small{display:none}.pf-badge{padding:6px 12px 6px 9px}}
</style>
</head>
@@@ -445,8 +591,10 @@ ${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links
<div class="pf-who"><h1>${badgeId ? `<img class="pf-name-badge" src="https://openvibe.media/o/${badgeId}" alt="" title="${esc(badge.name)}" width="32" height="32">` : ''}${fxClass ? `<span class="ov-fx ${fxClass}" title="${esc(fx.name)}">${esc(name)}</span>` : esc(name)}</h1><p class="pf-handle">@${esc(u.username)}</p></div>
</div>
${bio ? `<p class="pf-bio">${esc(bio)}</p>` : ''}
++${isPublic && Array.isArray(publicLinks) && publicLinks.length ? `<nav aria-label="${esc(name)}'s links"><ul class="pf-links">${publicLinks.slice(0, 8).map((link) => { let href = ''; try { const parsed = new URL(link.url); if (parsed.protocol === 'https:' && !parsed.username && !parsed.password) href = parsed.href; } catch {} return href ? `<li><a rel="me noopener noreferrer" href="${esc(href)}">${esc(String(link.label || '').slice(0, 40))}</a></li>` : ''; }).join('')}</ul></nav>` : ''}
${isPublic ? `<ul class="pf-stats">${stats}</ul>` : '<p class="pf-private">This profile is private.</p>'}
</section>
++${isPublic ? publicPlacesHtml(places) : ''}
${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + gamesHtml(inv) + itemsHtml(inv, u) : ''}
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
@@@ -457,4 +605,4 @@ ${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact'
</html>`;
}
--module.exports = { createPublicProfiles, renderPage, monthOf, SUBJECT_RE };
++module.exports = { createPublicProfiles, renderPage, renderPersonPage, monthOf, SUBJECT_RE, PUBLIC_PLACE_TIMEOUT_MS, PUBLIC_PLACE_TTL_MS };
ddiff --git a/server/public-cors.js b/server/public-cors.js
iindex 69e675e9..65b135e0 100644
---- a/server/public-cors.js
++++ b/server/public-cors.js
@@@ -8,6 +8,7 @@
* 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)
++ * GET /api/v1/people/@<handle>/page the public person page (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
@@@ -29,6 +30,7 @@ function isPublicDiscoveryPath(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 (/^\/api\/v1\/people\/@[A-Za-z0-9_]{1,40}\/page$/.test(path)) return true; // public person page JSON
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/server/seo/routes.js b/server/seo/routes.js
iindex 39db1840..5db27089 100644
---- a/server/seo/routes.js
++++ b/server/seo/routes.js
@@@ -27,21 +27,27 @@ function robotsTxt() {
return seo.robotsTxt({ sitemaps: [`${HOST}/sitemap.xml`] }) + `# Agent manifest: ${HOST}/.well-known/agent.json\n`;
}
--function sitemapXml({ release = null } = {}) {
++function sitemapXml({ release = null, profiles = [] } = {}) {
const lastmod = release?.full()?.released_at || FALLBACK_LASTMOD;
const day = String(lastmod).slice(0, 10);
-- return seo.sitemapXml(PAGES.map((page) => ({ loc: `${HOST}${page.path}`, lastmod: day, changefreq: page.changefreq, priority: page.priority })));
++ return seo.sitemapXml([
++ ...PAGES.map((page) => ({ loc: `${HOST}${page.path}`, lastmod: day, changefreq: page.changefreq, priority: page.priority })),
++ ...profiles.map((username) => ({ loc: `https://openvibe.my/@${encodeURIComponent(username)}`, lastmod: day, changefreq: 'weekly', priority: 0.5 })),
++ ]);
}
--function createSeoRoutes({ release = null } = {}) {
++function createSeoRoutes({ release = null, db = null } = {}) {
const r = express.Router();
const robots = robotsTxt();
-- const sitemap = sitemapXml({ release });
r.get('/robots.txt', (_req, res) => {
res.set('Content-Type', 'text/plain; charset=utf-8').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(robots);
});
-- r.get('/sitemap.xml', (_req, res) => {
-- res.set('Content-Type', 'application/xml; charset=utf-8').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sitemap);
++ r.get('/sitemap.xml', async (_req, res, next) => {
++ try {
++ const profiles = db ? await db.prepare(`SELECT username FROM users WHERE COALESCE(is_banned, 0) = 0 AND COALESCE(is_anon, 0) = 0 AND COALESCE(profile_public, 1) = 1 AND merged_into IS NULL AND deleted_at IS NULL AND (COALESCE(public_page_bio, '') <> '' OR public_page_links <> '[]'::jsonb)`).all() : [];
++ const sitemap = sitemapXml({ release, profiles: profiles.map((row) => row.username) });
++ res.set('Content-Type', 'application/xml; charset=utf-8').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sitemap);
++ } catch (err) { next(err); }
});
return r;
}
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex a981bd88..a13fd191 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'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand']]);
++ 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'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', '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/connect-mcp.test.js b/test/connect-mcp.test.js
iindex 1b2e4a39..1946d5e4 100644
---- a/test/connect-mcp.test.js
++++ b/test/connect-mcp.test.js
@@@ -47,7 +47,7 @@ const { startApp } = require('./helpers/connect-app');
const gateway = createGateway({ db, config, defs: [def], defaultRequestOptions: app.options });
const expressApp = express();
expressApp.use(express.json({ limit: '256kb' }));
-- expressApp.post('/mcp', createMcpHandler({ db, config, gateway }));
++ expressApp.post('/mcp', createMcpHandler({ db, config, gateway, publicPage: async (handle) => ({ username: handle, profile_url: `https://openvibe.my/@${handle}` }) }));
const server = http.createServer(expressApp);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${server.address().port}/mcp`;
@@@ -69,14 +69,23 @@ const { startApp } = require('./helpers/connect-app');
try {
const listed = await client.listTools();
const tools = listed.tools || listed || [];
-- assert.strictEqual(tools.length, 0, 'no grant yet, no tools');
++ assert.deepStrictEqual(tools.map((tool) => tool.name), ['network.person_page.read'], 'the public read is available without an app grant');
await callers.setGrant(db, made.caller.id, 'testapp', 'read', owner);
const after = await client.listTools();
const afterTools = after.tools || after || [];
-- assert.strictEqual(afterTools.length, 1);
-- assert.strictEqual(afterTools[0].name, 'testapp.read');
-- assert.ok(afterTools[0].inputSchema, 'the tool carries its schema');
-- assert.strictEqual(afterTools[0].annotations.readOnlyHint, true);
++ assert.strictEqual(afterTools.length, 2);
++ const appTool = afterTools.find((tool) => tool.name === 'testapp.read');
++ assert.ok(appTool.inputSchema, 'the tool carries its schema');
++ assert.strictEqual(appTool.annotations.readOnlyHint, true);
++ } finally { await client.close(); }
++ });
++
++ await check('the MCP public person-page tool returns only its public read', async () => {
++ const client = await connect(key);
++ try {
++ const out = await client.callTool({ name: 'network.person_page.read', arguments: { handle: 'ana' } });
++ assert.ok(!out.isError);
++ assert.match(out.content[0].text, /openvibe\.my\/@ana/);
} finally { await client.close(); }
});
ddiff --git a/test/public-cors.test.js b/test/public-cors.test.js
iindex ed13ccc1..1e8f1fde 100644
---- a/test/public-cors.test.js
++++ b/test/public-cors.test.js
@@@ -10,7 +10,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
(async () => {
// Path decision
-- for (const p of ['/.well-known/openvibe', '/api/v1/registry', '/api/v1/registry/', '/api/v1/registry/services', '/api/v1/registry/services/network',
++ for (const p of ['/.well-known/openvibe', '/api/v1/registry', '/api/v1/registry/', '/api/v1/registry/services', '/api/v1/registry/services/network', '/api/v1/people/@Ana/page',
'/api/v1/registry/domains/openvibe.community', '/api/v1/registry/capabilities/media.object.upload', '/contracts/identity/subject-ref.v1.json', '/contracts/events/payloads/deals.watch.matched.v1.json']) {
assert.ok(publicCors.isPublicDiscoveryPath(p), `${p} is public`);
}
ddiff --git a/test/public-profile.test.js b/test/public-profile.test.js
iindex 07343c5b..86444339 100644
---- a/test/public-profile.test.js
++++ b/test/public-profile.test.js
@@@ -11,7 +11,7 @@ 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 { createPublicProfiles, renderPersonPage, monthOf, PUBLIC_PLACE_TIMEOUT_MS, PUBLIC_PLACE_TTL_MS } = require('../server/profile/public');
const publicCors = require('../server/public-cors');
const A = 'usr_01JZ0000000000000000000AAA';
@@@ -49,6 +49,7 @@ const INSTANCES = [
('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("UPDATE users SET public_page_bio = ?, public_page_links = ? WHERE id = ?").run('Hello there', JSON.stringify([{ label: 'My site', url: 'https://example.com/' }]), ana.id);
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);
@@@ -60,6 +61,13 @@ const INSTANCES = [
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/streams/channel/Ana/live') return json(200, { channel: { username: 'Ana', display_name: 'Ana' }, streams: [{ id: 9 }] });
++ if (u.pathname === '/api/streams/channel/Ana') return json(200, { channel: { username: 'Ana', display_name: 'Ana' }, vods: [{ id: 'vod_public', watch_id: 'tdLVs5wad4A', title: 'First video', visibility: 'public' }, { id: 'vod_private', title: 'Private video', visibility: 'private' }] });
++ if (u.pathname === '/api/pastes/by-user/Ana') return json(200, { pastes: [{ slug: 'first-paste', title: 'A public note', visibility: 'public' }, { slug: 'hidden-paste', title: 'Hidden note', visibility: 'unlisted' }] });
++ if (u.pathname === '/spaces') {
++ res.writeHead(200, { 'Content-Type': 'text/html' });
++ return res.end('<article class="sp-card"><a class="sp-card-main" href="/spaces/ana-site"><span class="sp-card-badge is-live">Published</span><h3>Ana Site</h3><p>Public site</p></a><footer><span>by @Ana</span></footer></article><article class="sp-card"><a class="sp-card-main" href="/spaces/hidden-site"><span class="sp-card-badge is-live">Unlisted</span><h3>Hidden Site</h3><p>Private site</p></a><footer><span>by @Ana</span></footer></article>');
++ }
if (u.pathname === '/api/v1/kinds') return json(200, { kinds: KINDS });
if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: { 'live.hat:hat': { instance_id: INSTANCES[0].id, definition_id: INSTANCES[0].definition_id }, 'live.name_effect:name_effect': { instance_id: INSTANCES[1].id, definition_id: INSTANCES[1].definition_id, token: 'name-fx-rainbow' }, 'network.badge:badge': { instance_id: INSTANCES[3].id, definition_id: INSTANCES[3].definition_id, media_id: 'med_01JZ00000000000000000000B1' } } });
if (u.pathname === `/api/v1/people/${A}/items`) return json(200, { subject: A, instances: INSTANCES, definitions: DEFS, next_cursor: null });
@@@ -70,24 +78,29 @@ const INSTANCES = [
await new Promise((r) => inv.listen(0, '127.0.0.1', r));
let clock = Date.now();
const selfToken = (aud, caps) => `tok:${aud}:${caps.join(',')}`;
-- const profiles = createPublicProfiles({ db, selfToken, inventoryUrl: `http://127.0.0.1:${inv.address().port}`, questUrl: `http://127.0.0.1:${inv.address().port}`, playUrl: `http://127.0.0.1:${inv.address().port}`, now: () => clock, log: { warn() {} } });
++ const fakeService = `http://127.0.0.1:${inv.address().port}`;
++ const profiles = createPublicProfiles({ db, selfToken, inventoryUrl: fakeService, questUrl: fakeService, playUrl: fakeService, liveUrl: fakeService, communityUrl: fakeService, spaceUrl: fakeService, now: () => clock, log: { warn() {} } });
const app = express();
app.get('/@:username', profiles.pageHandler);
++ app.get('/api/v1/people/@:handle/page', profiles.pageApi);
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() }; };
++ const get = async (p, headers = {}) => { const r = await fetch(b + p, { redirect: 'manual', headers }); return { status: r.status, headers: r.headers, text: await r.text() }; };
try {
// A public profile.
-- let r = await get('/@ana');
++ let r = await get('/@Ana');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('<h1><img class="pf-name-badge" src="https://openvibe.media/o/med_01JZ00000000000000000000B1" alt="" title="OG Viewer" width="32" height="32"><span class="ov-fx name-fx-rainbow" title="Rainbow">Ana</span></h1>') && r.text.includes('@Ana'), 'her community badge, then her name in its effect');
assert.ok(r.text.includes('<link rel="stylesheet" href="/shared/items.css">'), 'with the shared effects stylesheet');
assert.ok(/<span class="pf-hat" title="Royal Crown" aria-hidden="true">👑<\/span>/.test(r.text), 'her hat sits on her picture');
-- 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('<script>alert(1)</script>') && r.text.includes('Hello there'), 'the saved public-page bio is shown as text');
++ assert.ok(r.text.includes('href="https://example.com/"'), 'HTTPS public links render');
++ assert.ok(r.text.includes('aria-labelledby="pf-places"') && r.text.includes('Live now') && r.text.includes('First video') && r.text.includes('A public note') && r.text.includes('Ana Site'));
++ assert.ok(!r.text.includes('Private video') && !r.text.includes('Hidden note') && !r.text.includes('Hidden Site'), 'private or unlisted places are omitted');
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'));
@@@ -97,7 +110,10 @@ const INSTANCES = [
assert.ok(r.text.includes('--pf-color:#ff0066'));
assert.ok(r.text.includes('https://openvibe.my/inventory/itd_01JZ00000000000000000001R1'), 'items link to their Inventory page');
assert.ok(!/name="robots" content="noindex"/.test(r.text));
-- assert.ok(calls.filter((c) => !c.path.includes('/badges') && !c.path.endsWith('/achievements')).every((c) => c.auth === 'Bearer tok:openvibe.inventory:inventory.item.read'), 'Network\'s own token for Inventory');
++ r = await get('/@ana');
++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, '/@Ana'], 'case variants redirect to the canonical handle');
++ r = await get('/@Ana');
++ assert.ok(calls.filter((c) => c.path === '/api/v1/kinds' || c.path === `/api/v1/people/${A}/equipped` || c.path === `/api/v1/people/${A}/items`).every((c) => c.auth === 'Bearer tok:openvibe.inventory:inventory.item.read'), 'Network\'s own token for Inventory');
assert.strictEqual(calls.find((c) => c.path.endsWith('/achievements')).auth, '', 'Play\'s read is public: no token');
assert.ok(r.text.includes('id="pf-games"') && r.text.includes('Salvage &lt;Rush&gt;') && !r.text.includes('Salvage <Rush>'), 'games, escaped');
assert.ok(/pf-game-n">2 achievements<[\s\S]*Big Haul[\s\S]*First Haul[\s\S]*Vibe Heroes[\s\S]*1 achievement</.test(r.text), 'each game with its achievements, newest first');
@@@ -118,7 +134,12 @@ const INSTANCES = [
assert.ok(/@media \(max-width:560px\)\{[^\n]*\.pf-badge small\{display:none\}/.test(r.text), 'on phones a chip drops its month (still in its title), so two fit a row');
assert.ok(/<span class="pf-stat-k">Badges<\/span><span class="pf-stat-v">2<\/span>/.test(r.text));
-- // Cached for a minute: a second view reads nothing.
++ // Place reads time out quickly and stay cached for five minutes across page and API reads.
++ assert.strictEqual(PUBLIC_PLACE_TIMEOUT_MS, 2000);
++ assert.strictEqual(PUBLIC_PLACE_TTL_MS, 300_000);
++ const placeCalls = calls.filter((c) => c.path.startsWith('/api/streams/channel/') || c.path === '/api/pastes/by-user/Ana' || c.path === '/spaces');
++ assert.deepStrictEqual(placeCalls.map((c) => c.path).sort(), ['/api/pastes/by-user/Ana', '/api/streams/channel/Ana', '/api/streams/channel/Ana/live', '/spaces'].sort());
++ // HTML may refresh after 60 seconds; place data stays cached.
const n = calls.length;
await get('/@Ana');
assert.strictEqual(calls.length, n, 'a second view within 60 s is cache');
@@@ -133,6 +154,22 @@ const INSTANCES = [
assert.deepStrictEqual(p.items.by_kind.map((k) => k.kind).sort(), ['live.hat', 'live.name_effect', 'live.particle', 'network.badge']);
assert.deepStrictEqual(p.badges.map((b) => b.id), ['thread-starter', 'first-follow']);
assert.deepStrictEqual([p.achievements.total, p.achievements.games.map((g) => [g.title, g.count])], [3, [['salvage-rush', 2], ['vibe-heroes', 1]]], 'achievements in the JSON every site reads');
++ r = await get('/api/v1/people/@Ana/page');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(JSON.parse(r.text).person.links, [{ label: 'My site', url: 'https://example.com/' }]);
++ assert.strictEqual(JSON.parse(r.text).person.bio, 'Hello there');
++ assert.strictEqual(JSON.parse(r.text).person.profile_url, 'https://openvibe.my/@Ana');
++ assert.strictEqual(JSON.parse(r.text).person.places.live.is_live, true);
++ assert.deepStrictEqual(JSON.parse(r.text).person.places.videos.map((v) => v.title), ['First video']);
++ assert.deepStrictEqual(JSON.parse(r.text).person.places.videos.map((v) => v.url), ['https://openvibe.video/watch?v=tdLVs5wad4A'], 'a video links to its watch page');
++ assert.deepStrictEqual(JSON.parse(r.text).person.places.community.map((v) => v.title), ['A public note']);
++ assert.deepStrictEqual(JSON.parse(r.text).person.places.spaces.map((v) => v.title), ['Ana Site']);
++ const person = JSON.parse(r.text).person;
++ r = await get('/api/v1/people/@ana/page');
++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, '/api/v1/people/@Ana/page']);
++ const newPage = renderPersonPage({ username: 'Ana' }, person, 'https://openvibe.my');
++ assert.ok(newPage.includes('<link rel="canonical" href="https://openvibe.my/@Ana">') && newPage.includes('First video') && newPage.includes('My site'));
++ assert.ok(!newPage.includes('pf-grid') && !newPage.includes('inventory'), 'the new page shows public places without private account inventory');
// A hidden profile: name and picture, nothing else, not indexed.
r = await get('/@hidden');
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 5e45505a..6455bb67 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -132,7 +132,7 @@ const REVIEWED = {
'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/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
-- 'server/profile/public.js fetch': [4, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030), OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) and OpenVibe.Play (OV_PLAY_INTERNAL_URL, loopback 5040) only; the paths are fixed, the subject is a checked usr_ id'],
++ 'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
'server/profile/avatar.js fetch': [4, 'Live (push) and Media (ingest, release) internal URLs from config; a picture is never fetched here — Media\'s ingest fetches it (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 5e79813c..a33e9238 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'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand']]);
++ 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'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', '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');