openvibers/openvibe.network Commits
Add one privacy-aware public user card read (#794)
Changes
nnew file mode 100644@@@ -0,0 +1,19 @@++# Shared user card read++++`GET /api/v1/people/:username/card` is a public, credential-free CORS read. The owner enables it with the existing owner-only `PUT /api/admin/settings`: `{"key":"user_card_enabled","value":"true","type":"boolean"}`. It defaults off and returns HTTP 503 `{"error":"user_card.disabled"}` until enabled. No production setting is changed by this migration.++++Example (optional fields are omitted when hidden or unavailable):++++```json++{"username":"Example","display_name":"Example","avatar_url":"https://openvibe.network/avatar/Example?s=160","profile_url":"https://openvibe.network/@Example","level":4,"streak":3,"badges_count":2,"followers":10,"live":{"now":true,"site":"live","url":"https://openvibe.live/@Example"},"channel_url":"https://openvibe.live/@Example","active_sites":[{"id":"live","name":"Live","url":"https://openvibe.live"}]}++```++++Network owns global levels (ask 241), followers, identity and history. Quest supplies the daily completion streak in the person's chosen time zone, with yesterday's streak retained until today's end, and the badge count. Live supplies current channel state; a missing channel or failed source is absent, never an invented offline state. No presence/last-seen timestamps, page titles, visited URLs, shared relationships or viewer-dependent data leave this public read.++++The aggregate has a process-local 30-second cache capped at 2,000 entries; concurrent misses share one promise. All card enrichments share this aggregate cache. Quest/Live card reads time out after two seconds, refuse redirects, and use only operator-configured service origins. Account eligibility and visibility are reread before cache use. HTTP responses use `Cache-Control: no-store` so browsers/CDNs cannot keep a hidden field alive. Unknown, banned, deleted, merged and anonymous accounts return 404; old handles temporarily redirect to the canonical read.++++`GET/PUT /api/v1/me/card-visibility` reads/patches booleans for `display_name`, `avatar_url`, `live`, `level`, `streak`, `badges_count`, `followers`, `active_sites`, `channel_url`. Fields default on except `active_sites`, which requires explicit opt-in to share site names from the last seven days of history. A private profile suppresses every enrichment; hidden Live places suppress live/channel links, hidden presence suppresses live state, and leaderboard opt-out suppresses level. Paused history or retaining private/mature history suppresses weekly sites. Public-place exclusions also filter sites. These stronger choices override card preferences. Preferences are included in account export and cleared on deletion.++++## Next for the design (For Fable)++++Build the common card appearance and the account controls for field visibility (including explicit weekly-site sharing), using this read and the Shared hook. No new page styling ships here. Relationship actions and site-specific rows remain follow-up work outside this read.nnew file mode 100644@@@ -0,0 +1,4 @@++-- phase: expand++-- Field visibility for the shared public card. History-derived activity requires an explicit opt-in.++ALTER TABLE users ADD COLUMN IF NOT EXISTS card_visibility jsonb NOT NULL DEFAULT '{}'::jsonb;++INSERT INTO site_settings(key, value, type) VALUES ('user_card_enabled', 'false', 'boolean') ON CONFLICT DO NOTHING;@@@ -165,7 +165,7 @@ async function networkPart(db, userId) {const truncated = [];const add = (name, content, cut) => { files.push({ name, content }); if (cut) truncated.push(name); };const keep = ['id', 'username', 'email', 'display_name', 'avatar_url', 'bio', 'role', 'profile_color', 'created_at', 'updated_at', 'last_seen',-- 'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'history_agents_allowed', 'history_keep_private', 'history_keep_mature', 'subject_id'];++ 'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'history_agents_allowed', 'history_keep_private', 'history_keep_mature', 'card_visibility', 'subject_id'];const account = {};for (const k of keep) if (k in u) account[k] = u[k];add('account.json', account);@@@ -413,7 +413,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {for (const id of userIds) {const u = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(id);const name = `deleted-${String(u.subject_id || id).slice(-12).toLowerCase()}`;-- const set = { username: name, email: null, password_hash: '!deleted', display_name: null, avatar_url: null, bio: '', profile_color: null, ban_reason: null,++ const set = { username: name, email: null, password_hash: '!deleted', display_name: null, avatar_url: null, bio: '', card_visibility: '{}', profile_color: null, ban_reason: null,name_effect: null, particle_effect: null, email_verified: 0, email_verified_at: null, email_bounced_at: null, email_bounce_reason: null, deleted_at: at };const keys = Object.keys(set).filter((k) => cols.includes(k));await db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => set[k]), id);@@@ -963,6 +963,9 @@ 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 userCards = require('./profile/card').createUserCards({ db, profiles: publicProfiles, selfToken, questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980', liveUrl: config.services.live.internalUrl });++app.get('/api/v1/people/:username/card', rateLimit({ windowMs: 60_000, max: 240 }), userCards.api);++app.use('/api/v1/me/card-visibility', rateLimit({ windowMs: 60_000, max: 60 }), userCards.settings(requireAuth));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);nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++const express = require('express');++const levels = require('../levels/store');++const follows = require('../identity/follows');++const { SITES } = require('../frame/sites');++const TTL_MS = 30_000;++const MAX_CACHE = 2000;++const FIELDS = ['display_name', 'avatar_url', 'live', 'level', 'streak', 'badges_count', 'followers', 'active_sites', 'channel_url'];++const defaults = () => Object.fromEntries(FIELDS.map(k => [k, k !== 'active_sites']));++const object = value => { try { return typeof value === 'string' ? JSON.parse(value) : (value || {}); } catch { return {}; } };++++function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'http://127.0.0.1:4980', liveUrl = 'http://127.0.0.1:3000', fetchImpl = (...args) => fetch(...args), now = Date.now }) {++ const cache = new Map();++ const pending = new Map();++ async function enabled() {++ const row = await db.prepare("SELECT value FROM site_settings WHERE key = 'user_card_enabled'").get();++ return row?.value === 'true';++ }++ async function json(base, path, token) {++ const response = await fetchImpl(String(base).replace(/\/+$/, '') + path, {++ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },++ signal: AbortSignal.timeout(2000), redirect: 'error',++ });++ if (!response.ok) throw new Error('Card source unavailable');++ return response.json();++ }++ async function policy(user) {++ const row = await db.prepare(`SELECT u.card_visibility, u.history_paused, u.history_keep_private, u.history_keep_mature,++ l.leaderboard, p.show_status, p.state FROM users u LEFT JOIN person_levels l ON l.user_id = u.id++ LEFT JOIN person_presence p ON p.subject_id = u.subject_id WHERE u.id = ?`).get(user.id);++ const visible = { ...defaults(), ...object(row.card_visibility) };++ const places = object(user.public_page_places);++ if (!Number(user.profile_public)) for (const key of FIELDS.slice(2)) visible[key] = false;++ if (places.live === false) { visible.live = false; visible.channel_url = false; }++ if (row.show_status === 0 || row.show_status === '0' || row.state === 'invisible') visible.live = false;++ if (row.leaderboard === false) visible.level = false;++ if (Number(row.history_paused) || Number(row.history_keep_private) || Number(row.history_keep_mature)) visible.active_sites = false;++ return { visible, places };++ }++ async function build(user, rules) {++ const { visible, places } = rules;++ const out = { username: user.username, profile_url: `https://openvibe.network/@${encodeURIComponent(user.username)}` };++ if (visible.display_name) out.display_name = user.display_name || user.username;++ if (visible.avatar_url) out.avatar_url = `https://openvibe.network/avatar/${encodeURIComponent(user.username)}?s=160`;++ const tasks = [];++ const hasSubject = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(user.subject_id || '');++ if (visible.level) tasks.push(levels.total(db, user.id).then(value => { out.level = value.level; }));++ if (visible.followers && hasSubject) tasks.push(follows.count(db, 'user', user.subject_id).then(value => { out.followers = Number(value); }));++ if (visible.badges_count && hasSubject) tasks.push(json(questUrl, `/api/v1/profiles/${encodeURIComponent(user.subject_id)}/badges`, selfToken('openvibe.quest', ['quest.badge.read'])).then(value => {++ if (Array.isArray(value.badges)) out.badges_count = value.badges.length;++ }).catch(() => {}));++ if (visible.streak && hasSubject) tasks.push(json(questUrl, `/api/v1/profiles/${encodeURIComponent(user.subject_id)}/streak`, selfToken('openvibe.quest', ['quest.badge.read'])).then(value => {++ if (Number.isSafeInteger(value.streak) && value.streak >= 0) out.streak = value.streak;++ }).catch(() => {}));++ if (visible.live || visible.channel_url) tasks.push(json(liveUrl, `/api/streams/channel/${encodeURIComponent(user.username)}/live`).then(value => {++ if (!value.channel || String(value.channel.username).toLowerCase() !== user.username.toLowerCase() || !Array.isArray(value.streams)) return;++ const url = `https://openvibe.live/@${encodeURIComponent(value.channel.username)}`;++ if (visible.channel_url) out.channel_url = url;++ if (visible.live) out.live = { now: value.streams.length > 0, ...(value.streams.length ? { site: 'live', url } : {}) };++ }).catch(() => {}));++ if (visible.active_sites) tasks.push(db.prepare(`SELECT DISTINCT service FROM user_history++ WHERE user_id = ? AND updated_at >= (now() - interval '7 days')::text`).all(user.id).then(rows => {++ const services = new Set(rows.map(r => r.service));++ out.active_sites = SITES.filter(s => (services.has(s.id) || services.has(s.service)) && s.host++ && places[s.id] !== false && !(places.media === false && ['media-hub', 'pics', 'video'].includes(s.id)))++ .map(s => ({ id: s.id, name: s.name, url: `https://${s.host}` }));++ }));++ await Promise.all(tasks);++ return out;++ }++ async function read(username) {++ const found = await profiles.find(username);++ if (!found) return null;++ if (found.moved) return { moved: found.moved };++ const user = found.user;++ // Recheck privacy and account eligibility on every read, including cache hits. A privacy change++ // changes the key immediately; an in-flight old build cannot overwrite the new policy's value.++ const rules = await policy(user);++ const key = JSON.stringify([user.id, user.subject_id, user.username, user.display_name, rules]);++ const hit = cache.get(key);++ if (hit && now() - hit.at < TTL_MS) return hit.value;++ if (pending.has(key)) return pending.get(key);++ const task = build(user, rules).then(value => {++ cache.set(key, { at: now(), value });++ if (cache.size > MAX_CACHE) cache.delete(cache.keys().next().value);++ return value;++ }).finally(() => pending.delete(key));++ pending.set(key, task);++ return task;++ }++ const handle = fn => async (req, res, next) => { try { await fn(req, res); } catch (e) { next(e); } };++ const api = handle(async (req, res) => {++ res.set('Cache-Control', 'no-store'); // Never let a browser/CDN outlive a privacy change.++ if (!await enabled()) return res.status(503).json({ error: 'user_card.disabled' });++ const value = await read(req.params.username);++ if (!value) return res.status(404).json({ error: 'user_card.not_found' });++ if (value.moved) return res.redirect(307, `/api/v1/people/${encodeURIComponent(value.moved)}/card`);++ return res.json(value);++ });++ function settings(requireAuth) {++ const router = express.Router();++ router.use(requireAuth, (req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });++ router.get('/', handle(async (req, res) => {++ const row = await db.prepare('SELECT card_visibility FROM users WHERE id = ?').get(req.user.id);++ res.json({ ...defaults(), ...object(row.card_visibility) });++ }));++ router.put('/', handle(async (req, res) => {++ if (!req.body || Array.isArray(req.body) || !Object.keys(req.body).length || Object.entries(req.body).some(([k, v]) => !FIELDS.includes(k) || typeof v !== 'boolean')) {++ return res.status(400).json({ error: 'user_card.invalid_visibility' });++ }++ await db.prepare('UPDATE users SET card_visibility = card_visibility || ?::jsonb WHERE id = ?').run(JSON.stringify(req.body), req.user.id);++ const row = await db.prepare('SELECT card_visibility FROM users WHERE id = ?').get(req.user.id);++ res.json({ ...defaults(), ...object(row.card_visibility) });++ }));++ return router;++ }++ return { api, settings, read };++}++module.exports = { createUserCards, TTL_MS, MAX_CACHE };@@@ -29,6 +29,7 @@ function isPublicDiscoveryPath(p) {const path = String(p || '');if (path === '/.well-known/openvibe') return true;if (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;++ if (/^\/api\/v1\/people\/[A-Za-z0-9_]{3,24}\/card$/.test(path)) return true;if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget readsif (path === '/api/v1/frame/today' || path === '/api/v1/frame/today/') return true;if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every site@@@ -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'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', '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'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', '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.@@@ -139,6 +139,7 @@ const REVIEWED = {'server/link/send.js fetch': [8, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys, the resumable transfer parts and the delivery list); page input supplies file bytes, never a URL or destination host'],'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],'server/actions/index.js fetch': [7, '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, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), 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/card.js fetch': [2, 'Operator-configured Quest and Live bases only, fixed paths with encoded canonical identities, no redirects, two second timeout'],'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)'],nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++const assert = require('assert');++const express = require('express');++const { getDb } = require('../server/db/database');++const { createPublicProfiles } = require('../server/profile/public');++const { createUserCards, TTL_MS } = require('../server/profile/card');++const cors = require('../server/public-cors');++(async () => {++ const db = getDb();++ const subject = 'usr_01JZ0000000000000000000AAA';++ await db.prepare("INSERT INTO users(username,password_hash,display_name,subject_id) VALUES ('CardTest','x','Card Test',?)").run(subject);++ const user = await db.prepare("SELECT id FROM users WHERE username='CardTest'").get();++ let clock = Date.now(), calls = 0, down = false, malformed = false;++ const fetchImpl = async (url, options) => {++ calls++;++ assert.strictEqual(options.redirect, 'error');++ if (down) throw new Error('down');++ if (malformed && url.includes('/profiles/')) return { ok: true, json: async () => ({ badges: null, streak: null }) };++ if (url.endsWith('/badges')) return { ok: true, json: async () => ({ badges: [{ id: 'one' }] }) };++ if (url.endsWith('/streak')) {++ assert.strictEqual(options.headers.Authorization, 'Bearer test-service');++ return { ok: true, json: async () => ({ streak: 3 }) };++ }++ return { ok: true, json: async () => ({ channel: { username: 'CardTest' }, streams: [{ id: 1 }] }) };++ };++ const profiles = createPublicProfiles({ db });++ const cards = createUserCards({ db, profiles, selfToken: () => 'test-service', fetchImpl, now: () => clock });++ const app = express(); app.use(express.json());++ app.get('/api/v1/people/:username/card', cors.publicCors, cards.api);++ app.use('/settings', cards.settings((req, res, next) => { req.user = user; next(); }));++ const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const get = () => fetch(base + '/api/v1/people/CardTest/card');++ try {++ assert.strictEqual((await get()).status, 503, 'owner switch defaults off');++ await db.prepare("UPDATE site_settings SET value='true' WHERE key='user_card_enabled'").run();++ const responses = await Promise.all([get(), get(), get()]);++ const card = await responses[0].json();++ assert.strictEqual(responses[0].headers.get('cache-control'), 'no-store');++ assert.strictEqual(responses[0].headers.get('access-control-allow-origin'), '*');++ assert.ok(cors.isPublicDiscoveryPath('/api/v1/people/CardTest/card'));++ assert.deepStrictEqual(card, { username: 'CardTest', display_name: 'Card Test', profile_url: 'https://openvibe.network/@CardTest', avatar_url: 'https://openvibe.network/avatar/CardTest?s=160', level: 1, followers: 0, badges_count: 1, streak: 3, channel_url: 'https://openvibe.live/@CardTest', live: { now: true, site: 'live', url: 'https://openvibe.live/@CardTest' } });++ assert.strictEqual(calls, 3, 'concurrent reads coalesce');++ await get(); assert.strictEqual(calls, 3, 'warm read reuses aggregate');++ clock += TTL_MS + 1; await get(); assert.strictEqual(calls, 6, 'expires after 30 seconds');++ await db.prepare("INSERT INTO user_history(user_id,service,type,title,url) VALUES (?, 'live', 'stream', 'private title', 'https://openvibe.live/@Someone')").run(user.id);++ const put = body => fetch(base + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });++ assert.strictEqual((await put({ active_sites: true })).status, 200);++ let visible = await (await get()).json();++ assert.ok(visible.active_sites.some(s => s.id === 'live'));++ assert.ok(!JSON.stringify(visible).includes('private title'));++ assert.strictEqual((await put({ active_sites: null })).status, 400);++ await put({ level: false, streak: false, followers: false, badges_count: false, avatar_url: false, active_sites: false });++ visible = await (await get()).json();++ for (const field of ['level', 'streak', 'followers', 'badges_count', 'avatar_url', 'active_sites']) assert.ok(!(field in visible), field + ' absent immediately');++ await db.prepare("UPDATE users SET public_page_places = '{\"live\":false}' WHERE id=?").run(user.id);++ visible = await (await get()).json();++ assert.ok(!('live' in visible) && !('channel_url' in visible));++ await db.prepare("UPDATE users SET profile_public=0, card_visibility='{}'::jsonb WHERE id=?").run(user.id);++ visible = await (await get()).json();++ assert.deepStrictEqual(Object.keys(visible).sort(), ['avatar_url', 'display_name', 'profile_url', 'username']);++ await db.prepare("UPDATE users SET profile_public=1, public_page_places='{}' WHERE id=?").run(user.id);++ await db.prepare("INSERT INTO person_presence(subject_id,state,show_status) VALUES (?, 'invisible', 1)").run(subject);++ visible = await (await get()).json();++ assert.ok(!('live' in visible), 'invisible overrides the card live setting');++ await db.prepare('DELETE FROM person_presence WHERE subject_id=?').run(subject);++ await db.prepare('INSERT INTO person_levels(user_id,leaderboard) VALUES (?, false)').run(user.id);++ visible = await (await get()).json();++ assert.ok(!('level' in visible), 'leaderboard opt-out overrides card visibility');++ await db.prepare("INSERT INTO username_history(user_id,old_username,new_username) VALUES (?, 'OldCard', 'CardTest')").run(user.id);++ const moved = await fetch(base + '/api/v1/people/OldCard/card', { redirect: 'manual' });++ assert.strictEqual(moved.status, 307);++ assert.strictEqual(moved.headers.get('location'), '/api/v1/people/CardTest/card');++ malformed = true; clock += TTL_MS + 1;++ visible = await (await get()).json();++ assert.ok(!('streak' in visible) && !('badges_count' in visible), 'null source values are omitted, never coerced to zero');++ malformed = false;++ down = true; clock += TTL_MS + 1;++ visible = await (await get()).json();++ assert.ok(!('streak' in visible) && !('badges_count' in visible) && !('live' in visible), 'source failures are absent, never zero');++ await db.prepare('UPDATE users SET is_banned=1 WHERE id=?').run(user.id);++ assert.strictEqual((await get()).status, 404, 'bans bypass cache');++ console.log('user-card: off switch, public shape, 30s coalesced cache, privacy on warm reads, opt-in sites, failure omission and bans passed');++ } finally { await new Promise(resolve => server.close(resolve)); }++})().catch(e => { console.error(e); process.exitCode = 1; });@@@ -56,7 +56,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'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', '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'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', '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// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+# Shared user card read
+
+`GET /api/v1/people/:username/card` is a public, credential-free CORS read. The owner enables it with the existing owner-only `PUT /api/admin/settings`: `{"key":"user_card_enabled","value":"true","type":"boolean"}`. It defaults off and returns HTTP 503 `{"error":"user_card.disabled"}` until enabled. No production setting is changed by this migration.
+
+Example (optional fields are omitted when hidden or unavailable):
+
+```json
+{"username":"Example","display_name":"Example","avatar_url":"https://openvibe.network/avatar/Example?s=160","profile_url":"https://openvibe.network/@Example","level":4,"streak":3,"badges_count":2,"followers":10,"live":{"now":true,"site":"live","url":"https://openvibe.live/@Example"},"channel_url":"https://openvibe.live/@Example","active_sites":[{"id":"live","name":"Live","url":"https://openvibe.live"}]}
+```
+
+Network owns global levels (ask 241), followers, identity and history. Quest supplies the daily completion streak in the person's chosen time zone, with yesterday's streak retained until today's end, and the badge count. Live supplies current channel state; a missing channel or failed source is absent, never an invented offline state. No presence/last-seen timestamps, page titles, visited URLs, shared relationships or viewer-dependent data leave this public read.
+
+The aggregate has a process-local 30-second cache capped at 2,000 entries; concurrent misses share one promise. All card enrichments share this aggregate cache. Quest/Live card reads time out after two seconds, refuse redirects, and use only operator-configured service origins. Account eligibility and visibility are reread before cache use. HTTP responses use `Cache-Control: no-store` so browsers/CDNs cannot keep a hidden field alive. Unknown, banned, deleted, merged and anonymous accounts return 404; old handles temporarily redirect to the canonical read.
+
+`GET/PUT /api/v1/me/card-visibility` reads/patches booleans for `display_name`, `avatar_url`, `live`, `level`, `streak`, `badges_count`, `followers`, `active_sites`, `channel_url`. Fields default on except `active_sites`, which requires explicit opt-in to share site names from the last seven days of history. A private profile suppresses every enrichment; hidden Live places suppress live/channel links, hidden presence suppresses live state, and leaderboard opt-out suppresses level. Paused history or retaining private/mature history suppresses weekly sites. Public-place exclusions also filter sites. These stronger choices override card preferences. Preferences are included in account export and cleared on deletion.
+
+## Next for the design (For Fable)
+
+Build the common card appearance and the account controls for field visibility (including explicit weekly-site sharing), using this read and the Shared hook. No new page styling ships here. Relationship actions and site-specific rows remain follow-up work outside this read.
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+-- phase: expand
+-- Field visibility for the shared public card. History-derived activity requires an explicit opt-in.
+ALTER TABLE users ADD COLUMN IF NOT EXISTS card_visibility jsonb NOT NULL DEFAULT '{}'::jsonb;
+INSERT INTO site_settings(key, value, type) VALUES ('user_card_enabled', 'false', 'boolean') ON CONFLICT DO NOTHING;
@@ -165,7 +165,7 @@ async function networkPart(db, userId) {
@@ -165,7 +165,7 @@ async function networkPart(db, userId) {
const truncated = [];
const truncated = [];
const add = (name, content, cut) => { files.push({ name, content }); if (cut) truncated.push(name); };
const add = (name, content, cut) => { files.push({ name, content }); if (cut) truncated.push(name); };
const keep = ['id', 'username', 'email', 'display_name', 'avatar_url', 'bio', 'role', 'profile_color', 'created_at', 'updated_at', 'last_seen',
const keep = ['id', 'username', 'email', 'display_name', 'avatar_url', 'bio', 'role', 'profile_color', 'created_at', 'updated_at', 'last_seen',
- 'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'history_agents_allowed', 'history_keep_private', 'history_keep_mature', 'subject_id'];
+ 'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'history_agents_allowed', 'history_keep_private', 'history_keep_mature', 'card_visibility', 'subject_id'];
const account = {};
const account = {};
for (const k of keep) if (k in u) account[k] = u[k];
for (const k of keep) if (k in u) account[k] = u[k];
add('account.json', account);
add('account.json', account);
@@ -413,7 +413,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -413,7 +413,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
for (const id of userIds) {
for (const id of userIds) {
const u = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(id);
const u = await db.prepare('SELECT subject_id FROM users WHERE id = ?').get(id);
const name = `deleted-${String(u.subject_id || id).slice(-12).toLowerCase()}`;
const name = `deleted-${String(u.subject_id || id).slice(-12).toLowerCase()}`;
- const set = { username: name, email: null, password_hash: '!deleted', display_name: null, avatar_url: null, bio: '', profile_color: null, ban_reason: null,
+ const set = { username: name, email: null, password_hash: '!deleted', display_name: null, avatar_url: null, bio: '', card_visibility: '{}', profile_color: null, ban_reason: null,
name_effect: null, particle_effect: null, email_verified: 0, email_verified_at: null, email_bounced_at: null, email_bounce_reason: null, deleted_at: at };
name_effect: null, particle_effect: null, email_verified: 0, email_verified_at: null, email_bounced_at: null, email_bounce_reason: null, deleted_at: at };
const keys = Object.keys(set).filter((k) => cols.includes(k));
const keys = Object.keys(set).filter((k) => cols.includes(k));
await db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => set[k]), id);
await db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => set[k]), id);
@@ -963,6 +963,9 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)
@@ -963,6 +963,9 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)
app.locals.avatarService = avatarService;
app.locals.avatarService = avatarService;
// Public profiles (plan T21 step 3): openvibe.network/@<username> and GET /api/v1/profiles/:username (CORS-open,
// Public profiles (plan T21 step 3): openvibe.network/@<username> and GET /api/v1/profiles/:username (CORS-open,
// server/public-cors.js), with the items from OpenVibe.Inventory (server/profile/public.js).
// server/public-cors.js), with the items from OpenVibe.Inventory (server/profile/public.js).
+const userCards = require('./profile/card').createUserCards({ db, profiles: publicProfiles, selfToken, questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980', liveUrl: config.services.live.internalUrl });
+app.get('/api/v1/people/:username/card', rateLimit({ windowMs: 60_000, max: 240 }), userCards.api);
+app.use('/api/v1/me/card-visibility', rateLimit({ windowMs: 60_000, max: 60 }), userCards.settings(requireAuth));
app.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);
app.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);
app.get('/api/v1/people/@:handle/page', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.pageApi);
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);
app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles.pageHandler);
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+const express = require('express');
+const levels = require('../levels/store');
+const follows = require('../identity/follows');
+const { SITES } = require('../frame/sites');
+const TTL_MS = 30_000;
+const MAX_CACHE = 2000;
+const FIELDS = ['display_name', 'avatar_url', 'live', 'level', 'streak', 'badges_count', 'followers', 'active_sites', 'channel_url'];
+const defaults = () => Object.fromEntries(FIELDS.map(k => [k, k !== 'active_sites']));
+const object = value => { try { return typeof value === 'string' ? JSON.parse(value) : (value || {}); } catch { return {}; } };
+
+function createUserCards({ db, profiles, selfToken = () => null, questUrl = 'http://127.0.0.1:4980', liveUrl = 'http://127.0.0.1:3000', fetchImpl = (...args) => fetch(...args), now = Date.now }) {
+ const cache = new Map();
+ const pending = new Map();
+ async function enabled() {
+ const row = await db.prepare("SELECT value FROM site_settings WHERE key = 'user_card_enabled'").get();
+ return row?.value === 'true';
+ }
+ async function json(base, path, token) {
+ const response = await fetchImpl(String(base).replace(/\/+$/, '') + path, {
+ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ signal: AbortSignal.timeout(2000), redirect: 'error',
+ });
+ if (!response.ok) throw new Error('Card source unavailable');
+ return response.json();
+ }
+ async function policy(user) {
+ const row = await db.prepare(`SELECT u.card_visibility, u.history_paused, u.history_keep_private, u.history_keep_mature,
+ l.leaderboard, p.show_status, p.state FROM users u LEFT JOIN person_levels l ON l.user_id = u.id
+ LEFT JOIN person_presence p ON p.subject_id = u.subject_id WHERE u.id = ?`).get(user.id);
+ const visible = { ...defaults(), ...object(row.card_visibility) };
+ const places = object(user.public_page_places);
+ if (!Number(user.profile_public)) for (const key of FIELDS.slice(2)) visible[key] = false;
+ if (places.live === false) { visible.live = false; visible.channel_url = false; }
+ if (row.show_status === 0 || row.show_status === '0' || row.state === 'invisible') visible.live = false;
+ if (row.leaderboard === false) visible.level = false;
+ if (Number(row.history_paused) || Number(row.history_keep_private) || Number(row.history_keep_mature)) visible.active_sites = false;
+ return { visible, places };
+ }
+ async function build(user, rules) {
+ const { visible, places } = rules;
+ const out = { username: user.username, profile_url: `https://openvibe.network/@${encodeURIComponent(user.username)}` };
+ if (visible.display_name) out.display_name = user.display_name || user.username;
+ if (visible.avatar_url) out.avatar_url = `https://openvibe.network/avatar/${encodeURIComponent(user.username)}?s=160`;
+ const tasks = [];
+ const hasSubject = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(user.subject_id || '');
+ if (visible.level) tasks.push(levels.total(db, user.id).then(value => { out.level = value.level; }));
+ if (visible.followers && hasSubject) tasks.push(follows.count(db, 'user', user.subject_id).then(value => { out.followers = Number(value); }));
+ if (visible.badges_count && hasSubject) tasks.push(json(questUrl, `/api/v1/profiles/${encodeURIComponent(user.subject_id)}/badges`, selfToken('openvibe.quest', ['quest.badge.read'])).then(value => {
+ if (Array.isArray(value.badges)) out.badges_count = value.badges.length;
+ }).catch(() => {}));
+ if (visible.streak && hasSubject) tasks.push(json(questUrl, `/api/v1/profiles/${encodeURIComponent(user.subject_id)}/streak`, selfToken('openvibe.quest', ['quest.badge.read'])).then(value => {
+ if (Number.isSafeInteger(value.streak) && value.streak >= 0) out.streak = value.streak;
+ }).catch(() => {}));
+ if (visible.live || visible.channel_url) tasks.push(json(liveUrl, `/api/streams/channel/${encodeURIComponent(user.username)}/live`).then(value => {
+ if (!value.channel || String(value.channel.username).toLowerCase() !== user.username.toLowerCase() || !Array.isArray(value.streams)) return;
+ const url = `https://openvibe.live/@${encodeURIComponent(value.channel.username)}`;
+ if (visible.channel_url) out.channel_url = url;
+ if (visible.live) out.live = { now: value.streams.length > 0, ...(value.streams.length ? { site: 'live', url } : {}) };
+ }).catch(() => {}));
+ if (visible.active_sites) tasks.push(db.prepare(`SELECT DISTINCT service FROM user_history
+ WHERE user_id = ? AND updated_at >= (now() - interval '7 days')::text`).all(user.id).then(rows => {
+ const services = new Set(rows.map(r => r.service));
+ out.active_sites = SITES.filter(s => (services.has(s.id) || services.has(s.service)) && s.host
+ && places[s.id] !== false && !(places.media === false && ['media-hub', 'pics', 'video'].includes(s.id)))
+ .map(s => ({ id: s.id, name: s.name, url: `https://${s.host}` }));
+ }));
+ await Promise.all(tasks);
+ return out;
+ }
+ async function read(username) {
+ const found = await profiles.find(username);
+ if (!found) return null;
+ if (found.moved) return { moved: found.moved };
+ const user = found.user;
+ // Recheck privacy and account eligibility on every read, including cache hits. A privacy change
+ // changes the key immediately; an in-flight old build cannot overwrite the new policy's value.
+ const rules = await policy(user);
+ const key = JSON.stringify([user.id, user.subject_id, user.username, user.display_name, rules]);
+ const hit = cache.get(key);
+ if (hit && now() - hit.at < TTL_MS) return hit.value;
+ if (pending.has(key)) return pending.get(key);
+ const task = build(user, rules).then(value => {
+ cache.set(key, { at: now(), value });
+ if (cache.size > MAX_CACHE) cache.delete(cache.keys().next().value);
+ return value;
+ }).finally(() => pending.delete(key));
+ pending.set(key, task);
+ return task;
+ }
+ const handle = fn => async (req, res, next) => { try { await fn(req, res); } catch (e) { next(e); } };
+ const api = handle(async (req, res) => {
+ res.set('Cache-Control', 'no-store'); // Never let a browser/CDN outlive a privacy change.
+ if (!await enabled()) return res.status(503).json({ error: 'user_card.disabled' });
+ const value = await read(req.params.username);
+ if (!value) return res.status(404).json({ error: 'user_card.not_found' });
+ if (value.moved) return res.redirect(307, `/api/v1/people/${encodeURIComponent(value.moved)}/card`);
+ return res.json(value);
+ });
+ function settings(requireAuth) {
+ const router = express.Router();
+ router.use(requireAuth, (req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
+ router.get('/', handle(async (req, res) => {
+ const row = await db.prepare('SELECT card_visibility FROM users WHERE id = ?').get(req.user.id);
+ res.json({ ...defaults(), ...object(row.card_visibility) });
+ }));
+ router.put('/', handle(async (req, res) => {
+ if (!req.body || Array.isArray(req.body) || !Object.keys(req.body).length || Object.entries(req.body).some(([k, v]) => !FIELDS.includes(k) || typeof v !== 'boolean')) {
+ return res.status(400).json({ error: 'user_card.invalid_visibility' });
+ }
+ await db.prepare('UPDATE users SET card_visibility = card_visibility || ?::jsonb WHERE id = ?').run(JSON.stringify(req.body), req.user.id);
+ const row = await db.prepare('SELECT card_visibility FROM users WHERE id = ?').get(req.user.id);
+ res.json({ ...defaults(), ...object(row.card_visibility) });
+ }));
+ return router;
+ }
+ return { api, settings, read };
+}
+module.exports = { createUserCards, TTL_MS, MAX_CACHE };
@@ -29,6 +29,7 @@ function isPublicDiscoveryPath(p) {
@@ -29,6 +29,7 @@ function isPublicDiscoveryPath(p) {
const path = String(p || '');
const path = String(p || '');
if (path === '/.well-known/openvibe') return true;
if (path === '/.well-known/openvibe') return true;
if (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;
if (/^\/api\/v1\/levels\/(?:batch|board|people\/[A-Za-z0-9_]{1,40})$/.test(path)) return true;
+ if (/^\/api\/v1\/people\/[A-Za-z0-9_]{3,24}\/card$/.test(path)) return true;
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
if (path === '/api/v1/changelog') return true; // the network changelog every site's "shipped" widget reads
if (path === '/api/v1/frame/today' || path === '/api/v1/frame/today/') return true;
if (path === '/api/v1/frame/today' || path === '/api/v1/frame/today/') return true;
if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every site
if (/^\/api\/v1\/profiles\/[A-Za-z0-9_]{1,40}$/.test(path)) return true; // public profiles, for every site
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['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'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', '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'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
@@ -139,6 +139,7 @@ const REVIEWED = {
@@ -139,6 +139,7 @@ const REVIEWED = {
'server/link/send.js fetch': [8, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys, the resumable transfer parts and the delivery list); page input supplies file bytes, never a URL or destination host'],
'server/link/send.js fetch': [8, 'browser-only Link Send requests to fixed same-origin routes (the session read, machine keys, the resumable transfer parts and the delivery list); page input supplies file bytes, never a URL or destination host'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/actions/index.js fetch': [7, '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, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/actions/index.js fetch': [7, '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, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), 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/card.js fetch': [2, 'Operator-configured Quest and Live bases only, fixed paths with encoded canonical identities, no redirects, two second timeout'],
'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/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/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/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+const assert = require('assert');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const { createPublicProfiles } = require('../server/profile/public');
+const { createUserCards, TTL_MS } = require('../server/profile/card');
+const cors = require('../server/public-cors');
+(async () => {
+ const db = getDb();
+ const subject = 'usr_01JZ0000000000000000000AAA';
+ await db.prepare("INSERT INTO users(username,password_hash,display_name,subject_id) VALUES ('CardTest','x','Card Test',?)").run(subject);
+ const user = await db.prepare("SELECT id FROM users WHERE username='CardTest'").get();
+ let clock = Date.now(), calls = 0, down = false, malformed = false;
+ const fetchImpl = async (url, options) => {
+ calls++;
+ assert.strictEqual(options.redirect, 'error');
+ if (down) throw new Error('down');
+ if (malformed && url.includes('/profiles/')) return { ok: true, json: async () => ({ badges: null, streak: null }) };
+ if (url.endsWith('/badges')) return { ok: true, json: async () => ({ badges: [{ id: 'one' }] }) };
+ if (url.endsWith('/streak')) {
+ assert.strictEqual(options.headers.Authorization, 'Bearer test-service');
+ return { ok: true, json: async () => ({ streak: 3 }) };
+ }
+ return { ok: true, json: async () => ({ channel: { username: 'CardTest' }, streams: [{ id: 1 }] }) };
+ };
+ const profiles = createPublicProfiles({ db });
+ const cards = createUserCards({ db, profiles, selfToken: () => 'test-service', fetchImpl, now: () => clock });
+ const app = express(); app.use(express.json());
+ app.get('/api/v1/people/:username/card', cors.publicCors, cards.api);
+ app.use('/settings', cards.settings((req, res, next) => { req.user = user; next(); }));
+ const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const get = () => fetch(base + '/api/v1/people/CardTest/card');
+ try {
+ assert.strictEqual((await get()).status, 503, 'owner switch defaults off');
+ await db.prepare("UPDATE site_settings SET value='true' WHERE key='user_card_enabled'").run();
+ const responses = await Promise.all([get(), get(), get()]);
+ const card = await responses[0].json();
+ assert.strictEqual(responses[0].headers.get('cache-control'), 'no-store');
+ assert.strictEqual(responses[0].headers.get('access-control-allow-origin'), '*');
+ assert.ok(cors.isPublicDiscoveryPath('/api/v1/people/CardTest/card'));
+ assert.deepStrictEqual(card, { username: 'CardTest', display_name: 'Card Test', profile_url: 'https://openvibe.network/@CardTest', avatar_url: 'https://openvibe.network/avatar/CardTest?s=160', level: 1, followers: 0, badges_count: 1, streak: 3, channel_url: 'https://openvibe.live/@CardTest', live: { now: true, site: 'live', url: 'https://openvibe.live/@CardTest' } });
+ assert.strictEqual(calls, 3, 'concurrent reads coalesce');
+ await get(); assert.strictEqual(calls, 3, 'warm read reuses aggregate');
+ clock += TTL_MS + 1; await get(); assert.strictEqual(calls, 6, 'expires after 30 seconds');
+ await db.prepare("INSERT INTO user_history(user_id,service,type,title,url) VALUES (?, 'live', 'stream', 'private title', 'https://openvibe.live/@Someone')").run(user.id);
+ const put = body => fetch(base + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ assert.strictEqual((await put({ active_sites: true })).status, 200);
+ let visible = await (await get()).json();
+ assert.ok(visible.active_sites.some(s => s.id === 'live'));
+ assert.ok(!JSON.stringify(visible).includes('private title'));
+ assert.strictEqual((await put({ active_sites: null })).status, 400);
+ await put({ level: false, streak: false, followers: false, badges_count: false, avatar_url: false, active_sites: false });
+ visible = await (await get()).json();
+ for (const field of ['level', 'streak', 'followers', 'badges_count', 'avatar_url', 'active_sites']) assert.ok(!(field in visible), field + ' absent immediately');
+ await db.prepare("UPDATE users SET public_page_places = '{\"live\":false}' WHERE id=?").run(user.id);
+ visible = await (await get()).json();
+ assert.ok(!('live' in visible) && !('channel_url' in visible));
+ await db.prepare("UPDATE users SET profile_public=0, card_visibility='{}'::jsonb WHERE id=?").run(user.id);
+ visible = await (await get()).json();
+ assert.deepStrictEqual(Object.keys(visible).sort(), ['avatar_url', 'display_name', 'profile_url', 'username']);
+ await db.prepare("UPDATE users SET profile_public=1, public_page_places='{}' WHERE id=?").run(user.id);
+ await db.prepare("INSERT INTO person_presence(subject_id,state,show_status) VALUES (?, 'invisible', 1)").run(subject);
+ visible = await (await get()).json();
+ assert.ok(!('live' in visible), 'invisible overrides the card live setting');
+ await db.prepare('DELETE FROM person_presence WHERE subject_id=?').run(subject);
+ await db.prepare('INSERT INTO person_levels(user_id,leaderboard) VALUES (?, false)').run(user.id);
+ visible = await (await get()).json();
+ assert.ok(!('level' in visible), 'leaderboard opt-out overrides card visibility');
+ await db.prepare("INSERT INTO username_history(user_id,old_username,new_username) VALUES (?, 'OldCard', 'CardTest')").run(user.id);
+ const moved = await fetch(base + '/api/v1/people/OldCard/card', { redirect: 'manual' });
+ assert.strictEqual(moved.status, 307);
+ assert.strictEqual(moved.headers.get('location'), '/api/v1/people/CardTest/card');
+ malformed = true; clock += TTL_MS + 1;
+ visible = await (await get()).json();
+ assert.ok(!('streak' in visible) && !('badges_count' in visible), 'null source values are omitted, never coerced to zero');
+ malformed = false;
+ down = true; clock += TTL_MS + 1;
+ visible = await (await get()).json();
+ assert.ok(!('streak' in visible) && !('badges_count' in visible) && !('live' in visible), 'source failures are absent, never zero');
+ await db.prepare('UPDATE users SET is_banned=1 WHERE id=?').run(user.id);
+ assert.strictEqual((await get()).status, 404, 'bans bypass cache');
+ console.log('user-card: off switch, public shape, 30s coalesced cache, privacy on warm reads, opt-in sites, failure omission and bans passed');
+ } finally { await new Promise(resolve => server.close(resolve)); }
+})().catch(e => { console.error(e); process.exitCode = 1; });
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['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'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', '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'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.