openvibers/openvibe.network Commits
One site list: the owner's eight groups, everywhere the network shows them (ask 274) (#796)
Changes
@@@ -22,7 +22,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.146.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0","otpauth": "9.6.0",@@@ -2679,9 +2679,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.399.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",-- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",++ "version": "0.400.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.0",++ "integrity": "sha512-37gMtbK0gQy1P3TdD0KauSDvV/p2J0fepd1qfsPhj8SWSZXHwry9zdwzJWDKiKsqKWnG5jvy9hLf4bH/1zIrpg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -26,7 +26,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.146.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0","otpauth": "9.6.0",@@@ -28,12 +28,15 @@const { isExtensionOrigin } = require('../auth/extension-origin');const express = require('express');const crypto = require('crypto');--const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');++const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MERGED_PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');const { createMenuCards } = require('./menu-cards');const seen = require('./seen');const toolsCatalog = require('../domains/catalog');const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;++// One number for "how many places the network lists": the menu, its tile count, pulse.sites and online.sites.++// 53 open sites (Network the front door included) plus Pics and Download, the two domains Video serves as entries.++const LISTED_TOTAL = LISTED.filter((s) => s.status === 'open').length;const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;const BASE_WEIGHT = { live: 7, tools: 6, space: 5, community: 4, games: 3, media: 2, network: 1 }; // cold-start order onlyconst MENU_BEACON = 'https://openvibe.network/api/frame/menu-click';@@@ -75,7 +78,7 @@ function siteForDestination(hostname) {if (!h) return null;const site = siteForHost(h);if (site) return site;-- const product = PRODUCTS.find((p) => h === p.host || h.endsWith('.' + p.host));++ const product = PRODUCTS.concat(MERGED_PRODUCTS).find((p) => h === p.host || h.endsWith('.' + p.host));return product ? SITES.find((s) => s.id === product.of) || null : null;}/** A user_history row's Frame site id: service alias first, the site id itself, then the row's URL host. */@@@ -177,14 +180,14 @@ async function createFrameService(db, config, analytics, { privateKey = null, is// frame_cache across restarts. A failed site keeps its last good card for 30 minutes; GET /api/frame carries// at most twelve, Live first while anyone is live, then the Frame's own ranking.const menuCards = createMenuCards({-- sites: () => SITES.filter((s) => s.status === 'open'),++ sites: () => LISTED_SITES.filter((s) => s.status === 'open'),rankOf: () => orderedOpen().map((s) => s.id),load: () => load('menu-cards'),save: (v) => save('menu-cards', v),onChange: () => { version = Date.now(); },});await menuCards.init();-- const openIds = () => new Set(SITES.filter((s) => s.status === 'open').map((s) => s.id));++ const openIds = () => new Set(LISTED_SITES.filter((s) => s.status === 'open').map((s) => s.id));/** Only open sites, never the site itself, at most six; drops duplicates and anything from an older seed. */function cleanNext(map) {const ids = openIds(); const out = {};@@@ -203,7 +206,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is* the first move is counted, so the menu's "Next from here" is never empty on a cold network. */function seedNext() {const ids = openIds(); const map = {};-- for (const site of SITES.filter((s) => s.status === 'open')) {++ for (const site of LISTED_SITES.filter((s) => s.status === 'open')) {const out = []; const seen = new Set([site.id]);const add = (id) => { if (out.length < 6 && ids.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } };for (const id of NEXT_SEED[site.id] || []) add(id);@@@ -292,7 +295,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, isbyFrom.get(pair.from).push(pair);}const seed = seedNext(); const map = {};-- for (const site of SITES.filter((s) => s.status === 'open')) {++ for (const site of LISTED_SITES.filter((s) => s.status === 'open')) {const out = []; const seen = new Set([site.id]);const add = (id) => { if (out.length < 6 && ids.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } };const moved = (byFrom.get(site.id) || []).sort((a, b) => b.people.size - a.people.size || b.moves - a.moves || a.to.localeCompare(b.to));@@@ -347,7 +350,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, isconst scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);function orderedOpen() {-- const open = SITES.filter(s => s.status === 'open');++ const open = LISTED_SITES.filter(s => s.status === 'open');// Real use decides, compared raw: normalising by one service's own analytics scale made Live's tens of// thousands of page views flatten every other site to nearly zero, so the cold-start BASE_WEIGHT decided the// order and Video (openvibe.video) sat under Tools, Community and Space despite 2,687 hits in 7 days (11-07).@@@ -359,7 +362,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, isfunction linkPool() {const { catalog } = toolsCatalog.peek();const pool = new Map();-- for (const s of SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: fullName(s), url: s.url || `https://${s.host}/`, about: s.tagline });++ for (const s of LISTED_SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: fullName(s), url: s.url || `https://${s.host}/`, about: s.tagline });for (const f of catalog.families) if (f.path) pool.set('family:' + f.id, { name: f.name, url: 'https://openvibe.tools' + f.path, about: f.tagline });for (const t of catalog.tools) pool.set('tool:' + t.id, { name: t.name, url: t.url, about: t.tagline });return pool;@@@ -376,7 +379,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, isconst pool = linkPool();const popular = new Set(popularTools(12).map(t => t.url));const links = [...pool.entries()].filter(([id, l]) => !id.startsWith('tool:') || popular.has(l.url)).map(([id, l]) => ({ id, name: l.name, about: l.about }));-- const body = { sites: SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: fullName(s), what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };++ const body = { sites: LISTED_SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: fullName(s), what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };let j = null;try { j = await fromAi(body); } catch { j = null; }if (!j || !Array.isArray(j.sites)) return; // keep the last good copy@@@ -403,7 +406,8 @@ async function createFrameService(db, config, analytics, { privateKey = null, isconst discoverIds = discover && Array.isArray(discover.ids) && discover.ids.length ? discover.ids.filter(id => open.some(s => s.id === id)) : open.map(s => s.id);return {updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },-- // Every open site, most used first, each followed by the products it serves on their own domains (`of`).++ // Every listed place, most used first, each followed by the products it serves on their own domains (`of`).++ // Media, Homes and Bench are merged sections, not entries; Pics and Download are entries after Video.nav: open.flatMap(s => [s, ...PRODUCTS.filter(p => p.of === s.id)]).map(s => ({ id: s.id, name: MENU_NAME[s.id] || s.name, url: s.url || `https://${s.host}/`, icon: ICON_ALIAS[s.icon] || s.icon, tagline: MENU_LINE[s.id] || s.tagline, group: groupOf(s.id), ...(s.of ? { of: s.of } : {}) })),// The menu's data: where people go next from each open site (totals only, no person), the fair discovery// order, and the click beacon (Shared sends it only when this object is present).@@@ -414,7 +418,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, iscards: menuCards.cards(),groups: GROUPS.map(g => ({ id: g.id, name: g.name })),// state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).-- soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: s.url || `https://${s.host}/`, icon: s.icon, state: s.state })),++ soon: LISTED.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: s.url || `https://${s.host}/`, icon: s.icon, state: s.state })),footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover: footerDiscover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },};}@@@ -475,7 +479,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, isreturn {live_now: live ? live.channels : null,watching: live ? live.watching : null,-- sites: orderedOpen().length,++ sites: LISTED_TOTAL,views_7d: views > 0 ? Math.round(views) : null,live: live ? live.list : null,updated: new Date().toISOString(),@@@ -487,7 +491,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, isawait pulseInflight;}res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'public, max-age=30, stale-while-revalidate=120', 'Content-Type': 'application/json; charset=utf-8' });-- res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: orderedOpen().length, views_7d: null, live: null, updated: null }));++ res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: LISTED_TOTAL, views_7d: null, live: null, updated: null }));});// ── GET /api/frame/online — who is on the network, and a few counts (owner, 2026-11-10) ────────────────────────@@@ -555,7 +559,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, isvisible,counts: await countsOf(nowMs),live: live ? { channels: live.channels, watching: live.watching, first: live.list.slice(0, 3).map((s) => ({ name: s.name, url: s.url })) } : { channels: 0, watching: 0, first: [] },-- sites: orderedOpen().length,++ sites: LISTED_TOTAL,views_7d: views > 0 ? Math.round(views) : null,updated: new Date(nowMs).toISOString(),};@@@ -604,7 +608,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is}}res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'public, max-age=30, stale-while-revalidate=120', 'Content-Type': 'application/json; charset=utf-8' });-- res.send(e ? e.body : JSON.stringify({ updated: null, members: null, guests: null, total: null, people: [], more: 0, live: null, counts: [], items: [], sites: orderedOpen().length, views_7d: null }));++ res.send(e ? e.body : JSON.stringify({ updated: null, members: null, guests: null, total: null, people: [], more: 0, live: null, counts: [], items: [], sites: LISTED_TOTAL, views_7d: null }));});// GET /api/frame/feed?kind=&site=&q=&following=1&cursor=&limit= and GET /api/frame/wall?limit=&after=&for= — the menu's Feed and wall@@@ -55,60 +55,74 @@ const fullName = (s) => (/^Open[A-Z]/.test(String(s && s.name)) ? s.name : `Open// The launcher (openvibe-shared navbar) lists every open site under one of these groups, most used first within// each. A site missing here lands in the last group, so a new manifest is never left out of the menu.--// The six neighbourhoods of the 2026-10-28 plan (§3.1): each label is two words, so it never collides with a site's own--// name, and they follow what a person came to do. Rooms not open yet (Cam, Tours, Surf, Fun, Meme, Casino, Today, Report,--// Vision, Website, Design, Domains, open.shoes) are named here so they land in their neighbourhood the day--// their manifest opens. The building blocks stay last, "For developers" (the tail of Make & build).++// The owner's regrouping (2026-11-10, plan ask 274): eight groups named for what a person came to do, in the order++// the menu, its folders, the footer and the home pages show them. Seven tiles a group at most; Video's two domains++// (Pics, Download) are list entries of their own, while Media, Homes and Bench are sections of Video, Rent and++// Report and keep their domains without a tile. Contracts' `site.neighbourhood` repeats these ids for every site.const GROUPS = [-- { id: 'streams', name: 'Streams & places', sites: ['live', 'media-hub', 'stream', 'pics', 'download', 'cam', 'tours'] },-- { id: 'games', name: 'Games & laughs', sites: ['games', 'surf', 'fun', 'meme', 'casino', 'quest'] },-- { id: 'answers', name: 'News & answers', sites: ['today', 'report', 'bench', 'news', 'search', 'wiki', 'blog', 'reviews', 'vision'] },-- { id: 'make', name: 'Make & build', sites: ['website', 'connect', 'design', 'image', 'domains', 'computer', 'codes', 'space', 'tools', 'host', 'bot'] },-- { id: 'shop', name: 'Shop & swap', sites: ['deals', 'coupons', 'shoes', 'rent', 'homes', 'work', 'food', 'trade'] },-- { id: 'people', name: 'People & you', sites: ['chat', 'mail', 'community', 'tips', 'vip', 'help', 'network', 'inventory'] },++ { id: 'watch', name: 'Watch & stream', sites: ['live', 'media-hub', 'pics', 'download', 'stream', 'cam', 'tours'] },++ { id: 'talk', name: 'Talk & belong', sites: ['community', 'chat', 'tips', 'vip', 'mail', 'help', 'network'] },++ { id: 'play', name: 'Play', sites: ['games', 'quest', 'surf', 'fun', 'meme', 'casino', 'inventory'] },++ { id: 'read', name: 'Read & ask', sites: ['news', 'today', 'report', 'search', 'wiki', 'blog', 'vision'] },++ { id: 'shop', name: 'Shop & money', sites: ['deals', 'coupons', 'shoes', 'reviews', 'trade', 'rent', 'work'] },++ { id: 'tools', name: 'Free tools', sites: ['tools', 'design', 'domains', 'host', 'image', 'lock', 'food'] },// Actor is the engine behind every agent (Codes' Auto, Vibe, the Dock call it): a developer service, not a second-- // place to chat with an agent (plan L39, 2026-10-31).-- { id: 'build', name: 'For developers', sites: ['services', 'exchange', 'actor', 'run', 'zone', 'events', 'media', 'watch'] },++ // place to chat with an agent (plan L39, 2026-10-31). It makes things with AI, beside the places that do.++ { id: 'make', name: 'Make with AI', sites: ['space', 'codes', 'bot', 'actor', 'computer', 'connect'] },++ { id: 'build', name: 'For developers', sites: ['services', 'exchange', 'run', 'zone', 'events', 'watch', 'git'] },];// What each site is, in a few words, for the menus (the launcher's rows are two to a line on a phone: about 22// characters fit). The manifests' taglines stay the sites' own sentences everywhere else; a site missing here shows// its tagline.const MENU_LINE = {live: 'Go live, watch streams', 'media-hub': 'Clips and past streams', stream: 'Stream everywhere', pics: 'Share pictures',-- download: 'Your files', tips: 'Support streamers', vip: 'Back your creators', computer: 'Your computer, your way', image: 'Edit photos in your browser', lock: 'Files only you can open',-- chat: 'Chat with anyone', mail: 'Masks and inboxes', community: 'Posts and pastes', blog: 'Write long-form', news: 'Sourced, not spun', wiki: 'Facts with sources',-- games: 'Play in your browser', quest: 'Earn badges', fun: 'Something to do',-- codes: 'Code with any agent', actor: 'The agent engine', space: 'Host a site or app', tools: 'Every online tool', bot: 'Drive a robot',-- deals: 'Deals people share', coupons: 'Codes that work', reviews: 'Honest reviews', work: 'Find a job', rent: 'Places to rent',-- homes: 'Homes for sale', food: 'Food near you', help: 'Help nearby', trade: 'Watch the markets', watch: 'Know when it changes',-- search: 'Search everything', host: 'Compare hosting', report: 'Your numbers, honest', vision: 'Ask a picture',-- exchange: 'AI models, one account', connect: 'Share a port with Link',-- network: 'Your account', inventory: 'Your items',-- services: 'APIs, SDK and keys', bench: 'Which AI is best', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', media: 'Media API',++ download: 'Your files', cam: 'Your camera, in private', tours: 'Cities, routes, stays',++ community: 'Posts and pastes', chat: 'Chat with anyone', tips: 'Support streamers', vip: 'Back your creators', mail: 'Masks and inboxes', help: 'Help nearby', network: 'Your account',++ games: 'Play in your browser', quest: 'Earn badges', surf: 'Surf maps and the web', fun: 'Something to do', meme: 'Memes with receipts', casino: 'Odds shown, chips free', inventory: 'Your items',++ news: 'Sourced, not spun', today: 'Your schedule', report: 'Your numbers, honest', search: 'Search everything', wiki: 'Facts with sources', blog: 'Write long-form', vision: 'Ask a picture',++ deals: 'Deals people share', coupons: 'Codes that work', shoes: 'Every shoe, every price', reviews: 'Honest reviews', trade: 'Watch the markets', rent: 'Places to rent', work: 'Find a job',++ tools: 'Every online tool', design: 'Try themes live', domains: 'Names and prices', host: 'Compare hosting', image: 'Edit photos in your browser', lock: 'Files only you can open', food: 'Food near you',++ space: 'Host a site or app', codes: 'Code with any agent', bot: 'Drive a robot', actor: 'The agent engine', computer: 'Your computer, your way', connect: 'Share a port with Link',++ services: 'APIs, SDK and keys', exchange: 'AI models, one account', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', watch: 'Know when it changes', git: 'Host and browse code',};// A site's name in the menus, where the rows are short words (Pics, Download, VIP, Tips): the product keeps its full name// everywhere else (its own site, page titles, sign-in). The owner, 2026-10-28: "label it simply Restream… it does look// a little weird" next to the others.--const MENU_NAME = { stream: 'Restream', bench: 'AI' };++const MENU_NAME = { stream: 'Restream' };const groupOf = (id) => (GROUPS.find(g => g.sites.includes(id)) || GROUPS[GROUPS.length - 1]).id;++// Sites whose own domain keeps answering but that are a section of another site: in the menu, the footer, the home++// pages and /sites they are not a tile of their own (owner, 2026-11-10). siteForHost still knows them.++// media → Video (openvibe.media stays the API and the embed host)++const MERGED_SITES = new Map([['media', 'media-hub']]);++// Products with a domain of their own that another service serves (its manifest lists the domain): in the menu--// while that service's site is open, right after it. They are not sites of their own (no ranking, no legal profile).++// while that service's site is open, right after it. They are not sites of their own (no ranking, no legal profile),++// but they are list entries: Pics and Download are what Video's drive shows on their own domains (owner, 2026-11-10).const PRODUCTS = [{ id: 'pics', name: 'Pics', host: 'openvibe.pics', icon: 'pics', of: 'media-hub', tagline: 'Share pictures, fast and open' },{ id: 'download', name: 'Download', host: 'openvibe.download', icon: 'download', of: 'media-hub', tagline: 'Your files, shared on your terms' },-- { id: 'homes', name: 'Homes', host: 'openvibe.homes', icon: 'homes', of: 'rent', tagline: 'Every home-for-sale site, one search' },-- // The move (2026-11-08): Bench's public pages are Report's AI section. The product keeps the old host for-- // redirects and links straight at the Report page; Report's manifest will name the host in the next Contracts-- // release, so the explicit `listed` flag is the bridge until then.-- { id: 'bench', name: 'AI', host: 'bench.openvibe.services', icon: 'bench', of: 'report', url: 'https://openvibe.report/ai', tagline: 'Which AI is best, and what it costs', listed: true },].filter((p) => {-- if (p.listed) return true;const s = SITES.find(x => x.id === p.of); const m = s && contracts.services.get(s.service);return m && (m.domains || []).includes(p.host);});++// Domains of a merged section (no tile): Homes is Rent's for-sale search, Bench is Report's AI section (its host++// already redirects). They stay in the host map so a page or a sign-in return keeps naming its own place.++const MERGED_PRODUCTS = [++ { id: 'homes', name: 'Homes', host: 'openvibe.homes', icon: 'homes', of: 'rent' },++ { id: 'bench', name: 'AI', host: 'bench.openvibe.services', icon: 'bench', of: 'report' },++];++++// The list the menu, the footer, the home pages and /sites show: every site except a merged section, plus the++// products above with a domain of their own (Pics, Download). Open sites in this list today: 55.++const LISTED_SITES = SITES.filter((s) => !MERGED_SITES.has(s.id));++const listed = (p) => {++ const owner = SITES.find((s) => s.id === p.of);++ return { ...p, service: p.of, what: p.tagline, status: owner ? owner.status : 'soon', state: owner ? owner.state : 'placeholder' };++};++const LISTED = LISTED_SITES.concat(PRODUCTS.map(listed));// Manifest icon names the shared icon set draws differently.const ICON_ALIAS = { 'media-hub': 'video' };@@@ -120,10 +134,10 @@ function siteForHost(hostname) {if (byHost.has(h)) return byHost.get(h);// A product on its own domain belongs to the site that serves it (Bench's host is Report's AI section now).// Before the subdomain pass, because services.openvibe.services would otherwise claim bench.openvibe.services.-- const product = PRODUCTS.find((p) => p.host === h || h.endsWith('.' + p.host));++ const product = PRODUCTS.concat(MERGED_PRODUCTS).find((p) => p.host === h || h.endsWith('.' + p.host));if (product) return SITES.find((s) => s.id === product.of) || null;for (const s of SITES) if (h.endsWith('.' + s.host)) return s;return null;}--module.exports = { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName };++module.exports = { SITES, LISTED_SITES, LISTED, siteForHost, GROUPS, groupOf, PRODUCTS, MERGED_SITES, MERGED_PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName };@@@ -26,18 +26,20 @@ const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" dat// The sites come from the frame's list (server/frame/sites.js): a site is open only when its service's// public domain serves the service itself (Network's exposure overlay), so the home page, the navbar and// "Opening next" always agree with what is actually running.--const { SITES: frameSites, GROUPS, groupOf, PRODUCTS, fullName } = require('../frame/sites');++const { LISTED, GROUPS, groupOf, fullName } = require('../frame/sites');const contracts = require('openvibe-contracts');const social = require('openvibe-shared/social');// OpenRestream is its own name, never "OpenVibe.OpenRestream" (the owner, 2026-10-28).const siteName = (site) => (site.name.includes('.') ? site.name : fullName(site));--const OPEN = frameSites.filter((site) => site.status === 'open' && site.id !== 'network');--// How many sites there are: every open one, this front door included, the same count /sites shows (server/sites/page.js).++const OPEN = LISTED.filter((site) => site.status === 'open' && site.id !== 'network');++// How many places the network lists: every open entry, this front door included, the same count the menu, /sites and++// the Frame's pulse/online answer (server/sites/page.js updates with it). Pics and Download count; Media, Homes and++// Bench are merged sections, not entries.// One number for the whole page: until 2026-11-06 the sites heading counted without the front door and the link under it// with it, so a phone showed "51 sites, one front door" above "All 52 sites" (the owner: "why isn't this count just// dynamic"). It always was computed; it now has one definition.--const SITE_COUNT = frameSites.filter((site) => site.status === 'open').length;--const SOON = frameSites.filter((site) => site.status !== 'open');++const SITE_COUNT = LISTED.filter((site) => site.status === 'open').length;++const SOON = LISTED.filter((site) => site.status !== 'open');const WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen', 'Twenty'];const inWords = (n) => WORDS[n] || String(n);const ACCOUNT = [@@@ -370,18 +372,12 @@ function body(catalog, act) {const popular = POPULAR.map((id) => byId.get(id)).filter(Boolean);const fams = catalog.families.filter((f) => f.url);const count = (f) => f.count || catalog.tools.filter((t) => t.family === f.id).length;-- // The service map: every open site under the launcher's groups (server/frame/sites.js GROUPS), so the 36 cards read-- // as six neighbourhoods, each with the products that live on another site's domain (openvibe.pics, …) beside it.-- // Every open site in its neighbourhood (server/frame/sites.js GROUPS), as a launcher under its short name (the domain-- // word: "Live", "Reviews"; the full name leads its one line): a phone shows the icons three or-- // four to a row, the way its home screen does; wider screens add each site's one line. The products that live on-- // another site's domain (openvibe.pics, …) are tiles of their neighbourhood too. /sites has the long descriptions.++ // The service map: every open place under the launcher's groups (server/frame/sites.js GROUPS), so the cards read++ // as the same eight groups, in the same order, as the menu. Pics and Download are list entries beside Video;++ // Media, Homes and Bench are merged sections and have no tile. /sites has the long descriptions.const groups = GROUPS.map((g) => ({name: g.name,-- items: [-- ...OPEN.filter((site) => groupOf(site.id) === g.id).map((site) => ({ icon: `ov:${site.icon}`, name: site.name, text: `${siteName(site)}: ${site.tagline || site.what}`, href: `https://${site.host}/` })),-- ...PRODUCTS.filter((x) => groupOf(x.id) === g.id).map((x) => ({ icon: `ov:${x.icon}`, name: x.name, text: x.tagline, href: x.url || `https://${x.host}/` })),-- ],++ items: OPEN.filter((site) => groupOf(site.id) === g.id).map((site) => ({ icon: `ov:${site.icon}`, name: site.name, text: `${siteName(site)}: ${site.tagline || site.what}`, href: `https://${site.host}/` })),})).filter((g) => g.items.length);const sites = showcase.apps({id: 'network', title: sitesHeading(),@@@ -10,10 +10,10 @@* sites, still excluding what the person uses; when Report does not answer at all, it is the fixed starter list.* The answer says which it is: { source, basis, generated_at, items: [...] }.*/--const { SITES, fullName } = require('../frame/sites');++const { LISTED_SITES, fullName } = require('../frame/sites');const DAY = 86_400_000;--const OPEN = SITES.filter((site) => site.status === 'open' && site.id !== 'network');++const OPEN = LISTED_SITES.filter((site) => site.status === 'open' && site.id !== 'network');const BY_SERVICE = new Map();for (const site of OPEN) { BY_SERVICE.set(site.service, site); BY_SERVICE.set(site.id, site); }// History's host-derived service name for a product whose site has another id.@@@ -1,28 +1,28 @@'use strict';--// /sites: the front door to the whole network, for people and for crawlers (SEO-9). Every open site is listed under--// the same six neighbourhoods the home page uses (server/frame/sites.js GROUPS), with the site's own tagline, and++// /sites: the front door to the whole network, for people and for crawlers (SEO-9). Every open entry is listed under++// the same eight groups the home page and the menu use (server/frame/sites.js GROUPS), with its own tagline, and// each site is anchored at its registry id (/sites#live) so it can be linked directly. One source: the registry the// home reads, so the hub cannot disagree with the home, and a site that is not open is never listed.const seo = require('openvibe-shared/seo');const icons = require('openvibe-shared/ov-icons');--const { SITES, GROUPS, groupOf, PRODUCTS, fullName } = require('../frame/sites');++const { LISTED, GROUPS, groupOf, fullName } = require('../frame/sites');const esc = seo.esc;const siteName = (site) => (site.name.includes('.') ? site.name : fullName(site));// The one-line purpose, in the home's own words: the tagline it renders on every card (the llms.txt and ItemList// descriptions use `what`, the longer text).const siteLine = (site) => site.tagline || site.what;--const openSites = (list = SITES) => list.filter((site) => site.status === 'open');++const openSites = (list = LISTED) => list.filter((site) => site.status === 'open');// The home counts the front door itself in "N sites, one account"; the hub lists it too (it is an open site, with the// account and themes behind every other one).--const groupsOf = (sites) => GROUPS.map((group) => ({ group, sites: sites.filter((site) => groupOf(site.id) === group.id), also: PRODUCTS.filter((p) => groupOf(p.id) === group.id) })).filter((x) => x.sites.length);++const groupsOf = (sites) => GROUPS.map((group) => ({ group, sites: sites.filter((site) => groupOf(site.id) === group.id) })).filter((x) => x.sites.length);// The network's own share card, the same one the home pages carry (an indexable page without og:image is the one// thing the SEO audit still finds on this page).const shareCard = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';const card = (site) => `<a class="sc-card" id="${esc(site.id)}" href="https://${esc(site.host)}/"><span class="sc-ic sc-ic-ov"><span class="ov-icon sc-ovi" data-icon="${esc(site.icon)}" style="--ovi-size:40px" aria-hidden="true">${icons.svg(site.icon)}</span></span><span><b>${esc(siteName(site))}</b><small>${esc(siteLine(site))}</small></span></a>`;--function render({ releaseId = '', sites = SITES } = {}) {++function render({ releaseId = '', sites = LISTED } = {}) {const open = openSites(sites);const groups = groupsOf(open);const count = open.length;@@@ -30,9 +30,9 @@ function render({ releaseId = '', sites = SITES } = {}) {const desc = `Every open OpenVibe site in one place, grouped by what people come to do, each with a link to its own home. One free account signs you in on all of them.`;const intro = 'Every open OpenVibe site, grouped by what people come to do; signing in once carries your name, theme and notifications to all of them.';const headings = new Map(groups.map(({ group }) => [group.id, `h-${group.id}`]));-- // The group anchors are prefixed: a group id ("games") can be a site id too, and one page cannot hold both.++ // The group anchors are prefixed: a group id can match a site id ("tools"), and one page cannot hold both.const jump = `<nav class="sites-jump" aria-labelledby="h-jump"><h2 id="h-jump">The groups</h2><ol>${groups.map(({ group, sites: list }) => `<li><a href="#group-${esc(group.id)}">${esc(group.name)}<small>${list.length}</small></a></li>`).join('')}</ol></nav>`;-- const body = groups.map(({ group, sites: list, also }) => `<section class="sc-sec" id="group-${esc(group.id)}" aria-labelledby="${esc(headings.get(group.id))}"><h2 id="${esc(headings.get(group.id))}">${esc(group.name)}</h2><div class="sc-grid">${list.map(card).join('')}</div>${also.length ? `<p class="sites-also">Also here: ${also.map((p) => `<a href="${esc(p.url || `https://${p.host}/`)}">${esc(p.name || p.host)}</a> (${esc(p.tagline.toLowerCase())})`).join(', ')}</p>` : ''}</section>`).join('\n');++ const body = groups.map(({ group, sites: list }) => `<section class="sc-sec" id="group-${esc(group.id)}" aria-labelledby="${esc(headings.get(group.id))}"><h2 id="${esc(headings.get(group.id))}">${esc(group.name)}</h2><div class="sc-grid">${list.map(card).join('')}</div></section>`).join('\n');const ld = {'@context': 'https://schema.org', '@type': 'CollectionPage',name: title, description: desc, url: 'https://openvibe.network/sites',@@@ -108,9 +108,6 @@ const CSS = `.sites-jump small{color:var(--text-muted);font-weight:700;font-size:12px;font-variant-numeric:tabular-nums}.sites .sc-sec:first-of-type{margin-top:40px}.sc-card[id]{scroll-margin-top:84px}--.sites-also{margin:12px 0 0;font-size:13.5px;color:var(--text-secondary)}--.sites-also a{color:var(--accent-light);font-weight:600;text-decoration:none}--.sites-also a:hover{text-decoration:underline}`;module.exports = { render, llmsText, siteLine, openSites, CSS };@@@ -8,14 +8,14 @@ const express = require('express');const http = require('http');const { getDb } = require('../server/db/database');const { createFrameService } = require('../server/frame/service');--const { SITES, siteForHost } = require('../server/frame/sites');++const { LISTED_SITES, siteForHost } = require('../server/frame/sites');const listen = (app) => new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });const url = (srv, path) => `http://127.0.0.1:${srv.address().port}${path}`;(async () => {const db = getDb();-- const openIds = SITES.filter((s) => s.status === 'open').map((s) => s.id);++ const openIds = LISTED_SITES.filter((s) => s.status === 'open').map((s) => s.id);for (let i = 1; i <= 5; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `menu${i}`, 'x');const ins = db.prepare("INSERT INTO user_history (user_id, service, type, title, url) VALUES (?, ?, 'page', 't', ?)");const insOld = db.prepare("INSERT INTO user_history (user_id, service, type, title, url, created_at) VALUES (?, ?, 'page', 't', ?, datetime('now', '-31 days'))");@@@ -10,7 +10,7 @@ const { createFrameService } = require('../server/frame/service');(async () => {const db = getDb();const svc = await createFrameService(db, { services: {} }, null, { selfToken: () => 'test-token' });-- assert.deepStrictEqual(svc.ranking().order.slice(0, 7), ['live', 'tools', 'space', 'community', 'games', 'media', 'network'], 'cold start: the hand-set base order');++ assert.deepStrictEqual(svc.ranking().order.slice(0, 6), ['live', 'tools', 'space', 'community', 'games', 'network'], 'cold start: the hand-set base order (Media is Video\'s section, not a tile)');for (let i = 1; i <= 4; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `rank${i}`, 'x');const day = "substring(ov_now(), 1, 10)";@@@ -5,9 +5,7 @@ const http = require('http');const contracts = require('openvibe-contracts');const { getDb } = require('../server/db/database');const { createFrameService, BANNED } = require('../server/frame/service');--const { siteForHost, GROUPS } = require('../server/frame/sites');--const HAS_MAIL = !!contracts.services.get('mail');--const HAS_GIT = !!contracts.services.get('git');++const { siteForHost, GROUPS, LISTED } = require('../server/frame/sites');(async () => {const db = getDb();@@@ -41,26 +39,25 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (thelet p = svc.payloadFor('openvibe.media');// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then// the test still follows what the installed manifests say.--const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock', 'image'];--if (HAS_MAIL) COLD_ORDER.splice(COLD_ORDER.indexOf('chat') + 1, 0, 'mail');--if (HAS_GIT) COLD_ORDER.splice(COLD_ORDER.indexOf('events') + 1, 0, 'git');++const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'network', 'chat', 'mail', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'git', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock', 'image'];assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');assert.ok(!p.nav.some(n => n.id === 'play'), 'Play is part of Games, not a menu entry of its own');--// The launcher: every open site in one of the groups, and the products other services serve on their own domains--// right after their site (Pics and Download after Video, Homes after Rent).--assert.deepStrictEqual(p.groups.map(g => g.name), ['Streams & places', 'Games & laughs', 'News & answers', 'Make & build', 'Shop & swap', 'People & you', 'For developers'], 'the six neighbourhoods (plan 2026-10-28 §3.1), the building blocks last');--assert.strictEqual(p.groups.find(g => g.id === 'build').name, 'For developers');--assert.deepStrictEqual(GROUPS.find(g => g.id === 'people').sites.slice(0, 3), ['chat', 'mail', 'community'], 'Email sits with Chat in the talk group, before Community');++assert.ok(!p.nav.some(n => ['media', 'homes', 'bench', 'website'].includes(n.id)), 'merged sections and the domain-only Website are not tiles');++// The launcher: every open place in one of the eight groups (owner, 2026-11-10), and the products other services++// serve on their own domains right after their site (Pics and Download after Video; Homes and Bench are sections).++assert.deepStrictEqual(p.groups.map(g => g.name), ['Watch & stream', 'Talk & belong', 'Play', 'Read & ask', 'Shop & money', 'Free tools', 'Make with AI', 'For developers'], 'the eight groups in the owner\'s order');++assert.deepStrictEqual(p.groups.map(g => g.id), ['watch', 'talk', 'play', 'read', 'shop', 'tools', 'make', 'build']);++assert.deepStrictEqual(GROUPS.find(g => g.id === 'talk').sites, ['community', 'chat', 'tips', 'vip', 'mail', 'help', 'network'], 'Email sits with Chat in the talk group');assert.ok(p.nav.every(n => p.groups.some(g => g.id === n.group)), 'every entry has a group');--assert.deepStrictEqual(p.nav.filter(n => n.of).map(n => [n.id, n.of, n.url]), [['pics', 'media-hub', 'https://openvibe.pics/'], ['download', 'media-hub', 'https://openvibe.download/'], ['homes', 'rent', 'https://openvibe.homes/'], ['bench', 'report', 'https://openvibe.report/ai']]);++assert.deepStrictEqual(p.nav.filter(n => n.of).map(n => [n.id, n.of, n.url]), [['pics', 'media-hub', 'https://openvibe.pics/'], ['download', 'media-hub', 'https://openvibe.download/']]);const at = (id) => p.nav.findIndex(n => n.id === id);--assert.ok(at('pics') === at('media-hub') + 1 && at('homes') === at('rent') + 1 && at('bench') === at('report') + 1, 'a product follows its site');--assert.deepStrictEqual([p.nav[at('media-hub')].icon, p.nav[at('media-hub')].group, p.nav[at('homes')].group, p.nav[at('tools')].group], ['video', 'streams', 'shop', 'make'], 'Video draws the video icon (there is no media-hub icon)');--assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.nav[at(id)].group), ['build', 'make', 'build', 'build', 'make'], 'robots and coding agents for people; the agent engine, compute and storage for developers (plan L39: Actor is the engine Codes, Vibe and the Dock call)');--// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).--[['games', 'games'], ['connect', 'make'], ['image', 'make'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['mail', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games'], ['vision', 'answers'], ['exchange', 'build'], ['fun', 'games'], ['cam', 'streams'], ['bench', 'answers'], ['casino', 'games'], ['tours', 'streams'], ['shoes', 'shop']]++assert.ok(at('pics') === at('media-hub') + 1 && at('download') === at('media-hub') + 2, 'Pics and Download follow their site');++assert.deepStrictEqual([p.nav[at('media-hub')].icon, p.nav[at('media-hub')].group, p.nav[at('tools')].group], ['video', 'watch', 'tools'], 'Video draws the video icon (there is no media-hub icon)');++assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.nav[at(id)].group), ['make', 'make', 'build', 'build', 'make'], 'robots and coding agents make with AI; compute and storage are for developers');++// Every tile is in exactly one group, each group has at most seven tiles, and the merged sections still answer.++[['games', 'play'], ['connect', 'make'], ['image', 'tools'], ['quest', 'play'], ['inventory', 'play'], ['watch', 'build'], ['download', 'watch'], ['search', 'read'], ['chat', 'talk'], ['mail', 'talk'], ['domains', 'tools'], ['report', 'read'], ['design', 'tools'], ['today', 'read'], ['surf', 'play'], ['meme', 'play'], ['vision', 'read'], ['exchange', 'build'], ['fun', 'play'], ['cam', 'watch'], ['casino', 'play'], ['tours', 'watch'], ['shoes', 'shop']].filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));--assert.strictEqual(p.nav.length, 56 + (HAS_MAIL ? 1 : 0) + (HAS_GIT ? 1 : 0)); // Computer and Locker since Contracts 0.347.0, Studio since 0.348.0 (2026-11-08).++assert.strictEqual(p.nav.length, 55, '53 open sites plus Pics and Download');assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');@@@ -157,7 +154,7 @@ for (let i = 0; i < 6; i++) await ins.run(1, 'tools', 'dns');assert.equal(r.headers.get('access-control-allow-origin'), '*');assert.match(r.headers.get('cache-control'), /max-age=30/);const j = await r.json();-- assert.deepStrictEqual([j.live_now, j.watching, j.sites], [2, 7, pulseSvc.ranking().order.length]);++ assert.deepStrictEqual([j.live_now, j.watching, j.sites], [2, 7, LISTED.filter((s) => s.status === 'open').length], 'the one open-place count (sites plus Pics and Download)');assert.ok(j.views_7d > 0 && j.updated, 'last week\'s views from the ranking');liveList.close();const down = await (await fetch(`http://127.0.0.1:${srv.address().port}/api/frame/pulse`)).json();@@@ -6,7 +6,7 @@ const assert = require('assert');const { parseHTML } = require('linkedom');const activity = require('../server/home/activity');const home = require('../server/home/render');--const sites = require('../server/frame/sites').SITES;++const sites = require('../server/frame/sites').LISTED;activity.setEnabled(false);@@@ -128,7 +128,7 @@ assert.ok(faqLd.mainEntity.length >= 5, 'the FAQ has its questions');faqLd.mainEntity.forEach((q) => assert.ok([...parseHTML(html).document.querySelectorAll('#faq h3')].some((el) => el.textContent === q.name), `the FAQ shows "${q.name}"`));assert.ok(faqLd.mainEntity.some((q) => /separate account/.test(q.name)), "Contracts' network FAQ is included");// The service map groups the sites the way the launcher does; numbers say only what was read.--assert.ok(/<h3 class="sc-apps-group">Streams & places<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make & build<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');++assert.ok(/<h3 class="sc-apps-group">Watch & stream<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make with AI<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');assert.ok(/<section class="net-num"/.test(html) && /sites, one account/.test(html), 'the numbers panel renders');assert.ok(!/services up/.test(html) && !/accounts<\/span>/.test(html), 'numbers nobody read are left out');assert.ok(!/Latest recordings|New jobs|Fresh coupon codes/.test(html), 'an empty column is left out');@@@ -6,10 +6,10 @@ const assert = require('assert');const activity = require('../server/home/activity');const home = require('../server/home/render');const sitesPage = require('../server/sites/page');--const { SITES } = require('../server/frame/sites');++const { LISTED } = require('../server/frame/sites');activity.setEnabled(false);--const expected = SITES.filter((s) => s.status === 'open').length;++const expected = LISTED.filter((s) => s.status === 'open').length;const { html } = home.render();const text = html.replace(/<script[\s\S]*?<\/script>/g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');const WORDS = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty'];@@@ -5,9 +5,9 @@ const assert = require('assert');const fs = require('fs');const path = require('path');const { SAME_AS, withSameAs } = require('../server/home/render');--const { SITES } = require('../server/frame/sites');++const { LISTED } = require('../server/frame/sites');--const open = SITES.filter((s) => s.status === 'open' && s.id !== 'network');++const open = LISTED.filter((s) => s.status === 'open' && s.id !== 'network');assert.strictEqual(SAME_AS.length, open.length + 2, 'GitHub, Discord and every open site');assert.ok(SAME_AS.includes('https://github.com/OpenVibers'));const missing = open.filter((s) => !SAME_AS.includes(`https://${s.host}`)).map((s) => s.host);@@@ -93,8 +93,8 @@ const cache = require('openvibe-shared/cache-policy');// ── /sites: the hub of the whole network (SEO-9) ──const sitesPage = require('../server/sites/page');-- const { SITES } = require('../server/frame/sites');-- const openSites = SITES.filter((site) => site.status === 'open');++ const { LISTED } = require('../server/frame/sites');++ const openSites = LISTED.filter((site) => site.status === 'open');const hub = await get('/sites');assert.strictEqual(hub.status, 200, '/sites answers 200 (it was a 404 before SEO-9)');assert.ok(hub.type.startsWith('text/html'), '/sites is HTML');@@@ -130,7 +130,7 @@ const cache = require('openvibe-shared/cache-policy');assert.deepStrictEqual(pageIds.filter((id, i) => pageIds.indexOf(id) !== i), [], 'no id repeats on the page');// Only `open`: an entry that is not open never reaches the page or its ItemList.const one = openSites[0];-- const closed = sitesPage.render({ sites: SITES.map((site) => (site.id === one.id ? { ...site, status: 'soon' } : site)) });++ const closed = sitesPage.render({ sites: LISTED.map((site) => (site.id === one.id ? { ...site, status: 'soon' } : site)) });assert.ok(!closed.includes(`id="${one.id}"`), 'a site that is not open is not anchored on the page');assert.strictEqual(JSON.parse(closed.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]).mainEntity.numberOfItems, openSites.length - 1, 'and not in the ItemList');// Findable: the sitemap lists it, and llms-full.txt carries its list.nnew file mode 100644@@@ -0,0 +1,50 @@++'use strict';++// The owner's one site list (2026-11-10, plan ask 274): eight groups, in this order, at most seven tiles each,++// every site in exactly one of them (or a named merge with a domain that keeps answering). The same list feeds the++// menu, its You-tab folders, the footer's Network column and the home pages' "More on OpenVibe".++const assert = require('assert');++const contracts = require('openvibe-contracts');++const {++ SITES, LISTED, LISTED_SITES, GROUPS, groupOf, PRODUCTS, MERGED_SITES, MERGED_PRODUCTS,++ MENU_LINE, MENU_NAME, siteForHost,++} = require('../server/frame/sites');++++const LABELS = ['Watch & stream', 'Talk & belong', 'Play', 'Read & ask', 'Shop & money', 'Free tools', 'Make with AI', 'For developers'];++assert.deepStrictEqual(GROUPS.map((g) => g.name), LABELS, 'the eight labels, in the order the menu, footer and homes show');++assert.deepStrictEqual(GROUPS.map((g) => g.id), ['watch', 'talk', 'play', 'read', 'shop', 'tools', 'make', 'build'], 'the ids Contracts `site.neighbourhood` and Shared folders use');++++// At most seven tiles a group, and every listed place in exactly one group.++for (const g of GROUPS) assert.ok(g.sites.length <= 7, `${g.name}: ${g.sites.length} tiles`);++for (const s of LISTED) assert.strictEqual(GROUPS.filter((g) => g.sites.includes(s.id)).length, 1, `${s.id}: exactly one group`);++assert.deepStrictEqual(GROUPS.map((g) => g.sites.length), [7, 7, 7, 7, 7, 7, 6, 7]);++assert.strictEqual(LISTED.filter((s) => s.status === 'open').length, 55, 'the one number: 53 open sites plus Pics and Download');++assert.deepStrictEqual(PRODUCTS.map((p) => LISTED.find((s) => s.id === p.id).status), ['open', 'open'], 'Pics and Download are open entries');++++// Every manifest site is in one group, or is one of the named merges: Media into Video, Bench into Report,++// Homes into Rent (no manifest of its own) and Play into Games. `website` has no site and is in no list.++const named = new Set([...MERGED_SITES.keys(), ...MERGED_PRODUCTS.map((p) => p.id), 'play']);++const manifestSites = contracts.services.manifests.filter((m) => m.site);++for (const m of manifestSites) {++ const id = m.site.icon;++ const grouped = GROUPS.some((g) => g.sites.includes(id));++ assert.ok(grouped || named.has(id), `${id}: in a group or a named merge`);++ assert.ok(!grouped || groupOf(id) === m.site.neighbourhood, `${id}: manifest neighbourhood ${m.site.neighbourhood} is its group`);++}++assert.ok(!GROUPS.some((g) => g.sites.includes('website')), 'Website (Space\'s second domain, no site) is in no list');++assert.ok(!['media', 'homes', 'bench'].some((id) => GROUPS.some((g) => g.sites.includes(id))), 'the merged sections have no tile');++++// Names everywhere: Email, Restream, Studio and Locker; never the id or the old product name.++const nameOf = (id) => MENU_NAME[id] || (LISTED.find((s) => s.id === id) || {}).name;++assert.deepStrictEqual(['mail', 'stream', 'image', 'lock'].map(nameOf), ['Email', 'Restream', 'Studio', 'Locker']);++assert.deepStrictEqual(['media', 'homes', 'bench', 'website'].filter((id) => LISTED.some((s) => s.id === id)), [], 'merged sections and Website are not listed');++++// Every tile keeps its one menu line.++for (const s of LISTED) assert.ok(MENU_LINE[s.id] && MENU_LINE[s.id].length <= 28, `${s.id}: one line (${MENU_LINE[s.id]})`);++++// A merged section keeps its own domain answering; the frame gives it the section's home.++assert.strictEqual(siteForHost('openvibe.homes').id, 'rent', 'Homes belongs to Rent');++assert.strictEqual(siteForHost('bench.openvibe.services').id, 'report', 'Bench belongs to Report');++assert.strictEqual(siteForHost('openvibe.media').id, 'media', 'Media still answers; it is Video\'s API, not a tile');++assert.ok(SITES.some((s) => s.id === 'media'), 'Media stays a known host');++++console.log(`site groups: ${LISTED.filter((s) => s.status === 'open').length} open places in ${GROUPS.length} groups, at most seven a group, every manifest site placed`);
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.146.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.146.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
@@ -2679,9 +2679,9 @@
@@ -2679,9 +2679,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.399.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",
+ "version": "0.400.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.0",
+ "integrity": "sha512-37gMtbK0gQy1P3TdD0KauSDvV/p2J0fepd1qfsPhj8SWSZXHwry9zdwzJWDKiKsqKWnG5jvy9hLf4bH/1zIrpg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.400.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.146.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.146.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.199.0",
"otpauth": "9.6.0",
"otpauth": "9.6.0",
@@ -28,12 +28,15 @@
@@ -28,12 +28,15 @@
const { isExtensionOrigin } = require('../auth/extension-origin');
const { isExtensionOrigin } = require('../auth/extension-origin');
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
-const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
+const { SITES, LISTED, LISTED_SITES, siteForHost, GROUPS, groupOf, PRODUCTS, MERGED_PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
const { createMenuCards } = require('./menu-cards');
const { createMenuCards } = require('./menu-cards');
const seen = require('./seen');
const seen = require('./seen');
const toolsCatalog = require('../domains/catalog');
const toolsCatalog = require('../domains/catalog');
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
+// One number for "how many places the network lists": the menu, its tile count, pulse.sites and online.sites.
+// 53 open sites (Network the front door included) plus Pics and Download, the two domains Video serves as entries.
+const LISTED_TOTAL = LISTED.filter((s) => s.status === 'open').length;
const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;
const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;
const BASE_WEIGHT = { live: 7, tools: 6, space: 5, community: 4, games: 3, media: 2, network: 1 }; // cold-start order only
const BASE_WEIGHT = { live: 7, tools: 6, space: 5, community: 4, games: 3, media: 2, network: 1 }; // cold-start order only
const MENU_BEACON = 'https://openvibe.network/api/frame/menu-click';
const MENU_BEACON = 'https://openvibe.network/api/frame/menu-click';
@@ -75,7 +78,7 @@ function siteForDestination(hostname) {
@@ -75,7 +78,7 @@ function siteForDestination(hostname) {
if (!h) return null;
if (!h) return null;
const site = siteForHost(h);
const site = siteForHost(h);
if (site) return site;
if (site) return site;
- const product = PRODUCTS.find((p) => h === p.host || h.endsWith('.' + p.host));
+ const product = PRODUCTS.concat(MERGED_PRODUCTS).find((p) => h === p.host || h.endsWith('.' + p.host));
return product ? SITES.find((s) => s.id === product.of) || null : null;
return product ? SITES.find((s) => s.id === product.of) || null : null;
}
}
/** A user_history row's Frame site id: service alias first, the site id itself, then the row's URL host. */
/** A user_history row's Frame site id: service alias first, the site id itself, then the row's URL host. */
@@ -177,14 +180,14 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -177,14 +180,14 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
// frame_cache across restarts. A failed site keeps its last good card for 30 minutes; GET /api/frame carries
// frame_cache across restarts. A failed site keeps its last good card for 30 minutes; GET /api/frame carries
// at most twelve, Live first while anyone is live, then the Frame's own ranking.
// at most twelve, Live first while anyone is live, then the Frame's own ranking.
const menuCards = createMenuCards({
const menuCards = createMenuCards({
- sites: () => SITES.filter((s) => s.status === 'open'),
+ sites: () => LISTED_SITES.filter((s) => s.status === 'open'),
rankOf: () => orderedOpen().map((s) => s.id),
rankOf: () => orderedOpen().map((s) => s.id),
load: () => load('menu-cards'),
load: () => load('menu-cards'),
save: (v) => save('menu-cards', v),
save: (v) => save('menu-cards', v),
onChange: () => { version = Date.now(); },
onChange: () => { version = Date.now(); },
});
});
await menuCards.init();
await menuCards.init();
- const openIds = () => new Set(SITES.filter((s) => s.status === 'open').map((s) => s.id));
+ const openIds = () => new Set(LISTED_SITES.filter((s) => s.status === 'open').map((s) => s.id));
/** Only open sites, never the site itself, at most six; drops duplicates and anything from an older seed. */
/** Only open sites, never the site itself, at most six; drops duplicates and anything from an older seed. */
function cleanNext(map) {
function cleanNext(map) {
const ids = openIds(); const out = {};
const ids = openIds(); const out = {};
@@ -203,7 +206,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -203,7 +206,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
* the first move is counted, so the menu's "Next from here" is never empty on a cold network. */
* the first move is counted, so the menu's "Next from here" is never empty on a cold network. */
function seedNext() {
function seedNext() {
const ids = openIds(); const map = {};
const ids = openIds(); const map = {};
- for (const site of SITES.filter((s) => s.status === 'open')) {
+ for (const site of LISTED_SITES.filter((s) => s.status === 'open')) {
const out = []; const seen = new Set([site.id]);
const out = []; const seen = new Set([site.id]);
const add = (id) => { if (out.length < 6 && ids.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } };
const add = (id) => { if (out.length < 6 && ids.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } };
for (const id of NEXT_SEED[site.id] || []) add(id);
for (const id of NEXT_SEED[site.id] || []) add(id);
@@ -292,7 +295,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -292,7 +295,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
byFrom.get(pair.from).push(pair);
byFrom.get(pair.from).push(pair);
}
}
const seed = seedNext(); const map = {};
const seed = seedNext(); const map = {};
- for (const site of SITES.filter((s) => s.status === 'open')) {
+ for (const site of LISTED_SITES.filter((s) => s.status === 'open')) {
const out = []; const seen = new Set([site.id]);
const out = []; const seen = new Set([site.id]);
const add = (id) => { if (out.length < 6 && ids.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } };
const add = (id) => { if (out.length < 6 && ids.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } };
const moved = (byFrom.get(site.id) || []).sort((a, b) => b.people.size - a.people.size || b.moves - a.moves || a.to.localeCompare(b.to));
const moved = (byFrom.get(site.id) || []).sort((a, b) => b.people.size - a.people.size || b.moves - a.moves || a.to.localeCompare(b.to));
@@ -347,7 +350,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -347,7 +350,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
function orderedOpen() {
function orderedOpen() {
- const open = SITES.filter(s => s.status === 'open');
+ const open = LISTED_SITES.filter(s => s.status === 'open');
// Real use decides, compared raw: normalising by one service's own analytics scale made Live's tens of
// Real use decides, compared raw: normalising by one service's own analytics scale made Live's tens of
// thousands of page views flatten every other site to nearly zero, so the cold-start BASE_WEIGHT decided the
// thousands of page views flatten every other site to nearly zero, so the cold-start BASE_WEIGHT decided the
// order and Video (openvibe.video) sat under Tools, Community and Space despite 2,687 hits in 7 days (11-07).
// order and Video (openvibe.video) sat under Tools, Community and Space despite 2,687 hits in 7 days (11-07).
@@ -359,7 +362,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -359,7 +362,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
function linkPool() {
function linkPool() {
const { catalog } = toolsCatalog.peek();
const { catalog } = toolsCatalog.peek();
const pool = new Map();
const pool = new Map();
- for (const s of SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: fullName(s), url: s.url || `https://${s.host}/`, about: s.tagline });
+ for (const s of LISTED_SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: fullName(s), url: s.url || `https://${s.host}/`, about: s.tagline });
for (const f of catalog.families) if (f.path) pool.set('family:' + f.id, { name: f.name, url: 'https://openvibe.tools' + f.path, about: f.tagline });
for (const f of catalog.families) if (f.path) pool.set('family:' + f.id, { name: f.name, url: 'https://openvibe.tools' + f.path, about: f.tagline });
for (const t of catalog.tools) pool.set('tool:' + t.id, { name: t.name, url: t.url, about: t.tagline });
for (const t of catalog.tools) pool.set('tool:' + t.id, { name: t.name, url: t.url, about: t.tagline });
return pool;
return pool;
@@ -376,7 +379,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -376,7 +379,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const pool = linkPool();
const pool = linkPool();
const popular = new Set(popularTools(12).map(t => t.url));
const popular = new Set(popularTools(12).map(t => t.url));
const links = [...pool.entries()].filter(([id, l]) => !id.startsWith('tool:') || popular.has(l.url)).map(([id, l]) => ({ id, name: l.name, about: l.about }));
const links = [...pool.entries()].filter(([id, l]) => !id.startsWith('tool:') || popular.has(l.url)).map(([id, l]) => ({ id, name: l.name, about: l.about }));
- const body = { sites: SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: fullName(s), what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };
+ const body = { sites: LISTED_SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: fullName(s), what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };
let j = null;
let j = null;
try { j = await fromAi(body); } catch { j = null; }
try { j = await fromAi(body); } catch { j = null; }
if (!j || !Array.isArray(j.sites)) return; // keep the last good copy
if (!j || !Array.isArray(j.sites)) return; // keep the last good copy
@@ -403,7 +406,8 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -403,7 +406,8 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const discoverIds = discover && Array.isArray(discover.ids) && discover.ids.length ? discover.ids.filter(id => open.some(s => s.id === id)) : open.map(s => s.id);
const discoverIds = discover && Array.isArray(discover.ids) && discover.ids.length ? discover.ids.filter(id => open.some(s => s.id === id)) : open.map(s => s.id);
return {
return {
updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
- // Every open site, most used first, each followed by the products it serves on their own domains (`of`).
+ // Every listed place, most used first, each followed by the products it serves on their own domains (`of`).
+ // Media, Homes and Bench are merged sections, not entries; Pics and Download are entries after Video.
nav: open.flatMap(s => [s, ...PRODUCTS.filter(p => p.of === s.id)]).map(s => ({ id: s.id, name: MENU_NAME[s.id] || s.name, url: s.url || `https://${s.host}/`, icon: ICON_ALIAS[s.icon] || s.icon, tagline: MENU_LINE[s.id] || s.tagline, group: groupOf(s.id), ...(s.of ? { of: s.of } : {}) })),
nav: open.flatMap(s => [s, ...PRODUCTS.filter(p => p.of === s.id)]).map(s => ({ id: s.id, name: MENU_NAME[s.id] || s.name, url: s.url || `https://${s.host}/`, icon: ICON_ALIAS[s.icon] || s.icon, tagline: MENU_LINE[s.id] || s.tagline, group: groupOf(s.id), ...(s.of ? { of: s.of } : {}) })),
// The menu's data: where people go next from each open site (totals only, no person), the fair discovery
// The menu's data: where people go next from each open site (totals only, no person), the fair discovery
// order, and the click beacon (Shared sends it only when this object is present).
// order, and the click beacon (Shared sends it only when this object is present).
@@ -414,7 +418,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -414,7 +418,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
cards: menuCards.cards(),
cards: menuCards.cards(),
groups: GROUPS.map(g => ({ id: g.id, name: g.name })),
groups: GROUPS.map(g => ({ id: g.id, name: g.name })),
// state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
// state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
- soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: s.url || `https://${s.host}/`, icon: s.icon, state: s.state })),
+ soon: LISTED.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: s.url || `https://${s.host}/`, icon: s.icon, state: s.state })),
footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover: footerDiscover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },
footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover: footerDiscover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },
};
};
}
}
@@ -475,7 +479,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -475,7 +479,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
return {
return {
live_now: live ? live.channels : null,
live_now: live ? live.channels : null,
watching: live ? live.watching : null,
watching: live ? live.watching : null,
- sites: orderedOpen().length,
+ sites: LISTED_TOTAL,
views_7d: views > 0 ? Math.round(views) : null,
views_7d: views > 0 ? Math.round(views) : null,
live: live ? live.list : null,
live: live ? live.list : null,
updated: new Date().toISOString(),
updated: new Date().toISOString(),
@@ -487,7 +491,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -487,7 +491,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
await pulseInflight;
await pulseInflight;
}
}
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'public, max-age=30, stale-while-revalidate=120', 'Content-Type': 'application/json; charset=utf-8' });
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'public, max-age=30, stale-while-revalidate=120', 'Content-Type': 'application/json; charset=utf-8' });
- res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: orderedOpen().length, views_7d: null, live: null, updated: null }));
+ res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: LISTED_TOTAL, views_7d: null, live: null, updated: null }));
});
});
// ── GET /api/frame/online — who is on the network, and a few counts (owner, 2026-11-10) ────────────────────────
// ── GET /api/frame/online — who is on the network, and a few counts (owner, 2026-11-10) ────────────────────────
@@ -555,7 +559,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -555,7 +559,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
visible,
visible,
counts: await countsOf(nowMs),
counts: await countsOf(nowMs),
live: live ? { channels: live.channels, watching: live.watching, first: live.list.slice(0, 3).map((s) => ({ name: s.name, url: s.url })) } : { channels: 0, watching: 0, first: [] },
live: live ? { channels: live.channels, watching: live.watching, first: live.list.slice(0, 3).map((s) => ({ name: s.name, url: s.url })) } : { channels: 0, watching: 0, first: [] },
- sites: orderedOpen().length,
+ sites: LISTED_TOTAL,
views_7d: views > 0 ? Math.round(views) : null,
views_7d: views > 0 ? Math.round(views) : null,
updated: new Date(nowMs).toISOString(),
updated: new Date(nowMs).toISOString(),
};
};
@@ -604,7 +608,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -604,7 +608,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
}
}
}
}
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'public, max-age=30, stale-while-revalidate=120', 'Content-Type': 'application/json; charset=utf-8' });
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'public, max-age=30, stale-while-revalidate=120', 'Content-Type': 'application/json; charset=utf-8' });
- res.send(e ? e.body : JSON.stringify({ updated: null, members: null, guests: null, total: null, people: [], more: 0, live: null, counts: [], items: [], sites: orderedOpen().length, views_7d: null }));
+ res.send(e ? e.body : JSON.stringify({ updated: null, members: null, guests: null, total: null, people: [], more: 0, live: null, counts: [], items: [], sites: LISTED_TOTAL, views_7d: null }));
});
});
// GET /api/frame/feed?kind=&site=&q=&following=1&cursor=&limit= and GET /api/frame/wall?limit=&after=&for= — the menu's Feed and wall
// GET /api/frame/feed?kind=&site=&q=&following=1&cursor=&limit= and GET /api/frame/wall?limit=&after=&for= — the menu's Feed and wall
@@ -55,60 +55,74 @@ const fullName = (s) => (/^Open[A-Z]/.test(String(s && s.name)) ? s.name : `Open
@@ -55,60 +55,74 @@ const fullName = (s) => (/^Open[A-Z]/.test(String(s && s.name)) ? s.name : `Open
// The launcher (openvibe-shared navbar) lists every open site under one of these groups, most used first within
// The launcher (openvibe-shared navbar) lists every open site under one of these groups, most used first within
// each. A site missing here lands in the last group, so a new manifest is never left out of the menu.
// each. A site missing here lands in the last group, so a new manifest is never left out of the menu.
-// The six neighbourhoods of the 2026-10-28 plan (§3.1): each label is two words, so it never collides with a site's own
-// name, and they follow what a person came to do. Rooms not open yet (Cam, Tours, Surf, Fun, Meme, Casino, Today, Report,
-// Vision, Website, Design, Domains, open.shoes) are named here so they land in their neighbourhood the day
-// their manifest opens. The building blocks stay last, "For developers" (the tail of Make & build).
+// The owner's regrouping (2026-11-10, plan ask 274): eight groups named for what a person came to do, in the order
+// the menu, its folders, the footer and the home pages show them. Seven tiles a group at most; Video's two domains
+// (Pics, Download) are list entries of their own, while Media, Homes and Bench are sections of Video, Rent and
+// Report and keep their domains without a tile. Contracts' `site.neighbourhood` repeats these ids for every site.
const GROUPS = [
const GROUPS = [
- { id: 'streams', name: 'Streams & places', sites: ['live', 'media-hub', 'stream', 'pics', 'download', 'cam', 'tours'] },
- { id: 'games', name: 'Games & laughs', sites: ['games', 'surf', 'fun', 'meme', 'casino', 'quest'] },
- { id: 'answers', name: 'News & answers', sites: ['today', 'report', 'bench', 'news', 'search', 'wiki', 'blog', 'reviews', 'vision'] },
- { id: 'make', name: 'Make & build', sites: ['website', 'connect', 'design', 'image', 'domains', 'computer', 'codes', 'space', 'tools', 'host', 'bot'] },
- { id: 'shop', name: 'Shop & swap', sites: ['deals', 'coupons', 'shoes', 'rent', 'homes', 'work', 'food', 'trade'] },
- { id: 'people', name: 'People & you', sites: ['chat', 'mail', 'community', 'tips', 'vip', 'help', 'network', 'inventory'] },
+ { id: 'watch', name: 'Watch & stream', sites: ['live', 'media-hub', 'pics', 'download', 'stream', 'cam', 'tours'] },
+ { id: 'talk', name: 'Talk & belong', sites: ['community', 'chat', 'tips', 'vip', 'mail', 'help', 'network'] },
+ { id: 'play', name: 'Play', sites: ['games', 'quest', 'surf', 'fun', 'meme', 'casino', 'inventory'] },
+ { id: 'read', name: 'Read & ask', sites: ['news', 'today', 'report', 'search', 'wiki', 'blog', 'vision'] },
+ { id: 'shop', name: 'Shop & money', sites: ['deals', 'coupons', 'shoes', 'reviews', 'trade', 'rent', 'work'] },
+ { id: 'tools', name: 'Free tools', sites: ['tools', 'design', 'domains', 'host', 'image', 'lock', 'food'] },
// Actor is the engine behind every agent (Codes' Auto, Vibe, the Dock call it): a developer service, not a second
// Actor is the engine behind every agent (Codes' Auto, Vibe, the Dock call it): a developer service, not a second
- // place to chat with an agent (plan L39, 2026-10-31).
- { id: 'build', name: 'For developers', sites: ['services', 'exchange', 'actor', 'run', 'zone', 'events', 'media', 'watch'] },
+ // place to chat with an agent (plan L39, 2026-10-31). It makes things with AI, beside the places that do.
+ { id: 'make', name: 'Make with AI', sites: ['space', 'codes', 'bot', 'actor', 'computer', 'connect'] },
+ { id: 'build', name: 'For developers', sites: ['services', 'exchange', 'run', 'zone', 'events', 'watch', 'git'] },
];
];
// What each site is, in a few words, for the menus (the launcher's rows are two to a line on a phone: about 22
// What each site is, in a few words, for the menus (the launcher's rows are two to a line on a phone: about 22
// characters fit). The manifests' taglines stay the sites' own sentences everywhere else; a site missing here shows
// characters fit). The manifests' taglines stay the sites' own sentences everywhere else; a site missing here shows
// its tagline.
// its tagline.
const MENU_LINE = {
const MENU_LINE = {
live: 'Go live, watch streams', 'media-hub': 'Clips and past streams', stream: 'Stream everywhere', pics: 'Share pictures',
live: 'Go live, watch streams', 'media-hub': 'Clips and past streams', stream: 'Stream everywhere', pics: 'Share pictures',
- download: 'Your files', tips: 'Support streamers', vip: 'Back your creators', computer: 'Your computer, your way', image: 'Edit photos in your browser', lock: 'Files only you can open',
- chat: 'Chat with anyone', mail: 'Masks and inboxes', community: 'Posts and pastes', blog: 'Write long-form', news: 'Sourced, not spun', wiki: 'Facts with sources',
- games: 'Play in your browser', quest: 'Earn badges', fun: 'Something to do',
- codes: 'Code with any agent', actor: 'The agent engine', space: 'Host a site or app', tools: 'Every online tool', bot: 'Drive a robot',
- deals: 'Deals people share', coupons: 'Codes that work', reviews: 'Honest reviews', work: 'Find a job', rent: 'Places to rent',
- homes: 'Homes for sale', food: 'Food near you', help: 'Help nearby', trade: 'Watch the markets', watch: 'Know when it changes',
- search: 'Search everything', host: 'Compare hosting', report: 'Your numbers, honest', vision: 'Ask a picture',
- exchange: 'AI models, one account', connect: 'Share a port with Link',
- network: 'Your account', inventory: 'Your items',
- services: 'APIs, SDK and keys', bench: 'Which AI is best', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', media: 'Media API',
+ download: 'Your files', cam: 'Your camera, in private', tours: 'Cities, routes, stays',
+ community: 'Posts and pastes', chat: 'Chat with anyone', tips: 'Support streamers', vip: 'Back your creators', mail: 'Masks and inboxes', help: 'Help nearby', network: 'Your account',
+ games: 'Play in your browser', quest: 'Earn badges', surf: 'Surf maps and the web', fun: 'Something to do', meme: 'Memes with receipts', casino: 'Odds shown, chips free', inventory: 'Your items',
+ news: 'Sourced, not spun', today: 'Your schedule', report: 'Your numbers, honest', search: 'Search everything', wiki: 'Facts with sources', blog: 'Write long-form', vision: 'Ask a picture',
+ deals: 'Deals people share', coupons: 'Codes that work', shoes: 'Every shoe, every price', reviews: 'Honest reviews', trade: 'Watch the markets', rent: 'Places to rent', work: 'Find a job',
+ tools: 'Every online tool', design: 'Try themes live', domains: 'Names and prices', host: 'Compare hosting', image: 'Edit photos in your browser', lock: 'Files only you can open', food: 'Food near you',
+ space: 'Host a site or app', codes: 'Code with any agent', bot: 'Drive a robot', actor: 'The agent engine', computer: 'Your computer, your way', connect: 'Share a port with Link',
+ services: 'APIs, SDK and keys', exchange: 'AI models, one account', run: 'Run jobs anywhere', zone: 'S3 storage', events: 'Event streams', watch: 'Know when it changes', git: 'Host and browse code',
};
};
// A site's name in the menus, where the rows are short words (Pics, Download, VIP, Tips): the product keeps its full name
// A site's name in the menus, where the rows are short words (Pics, Download, VIP, Tips): the product keeps its full name
// everywhere else (its own site, page titles, sign-in). The owner, 2026-10-28: "label it simply Restream… it does look
// everywhere else (its own site, page titles, sign-in). The owner, 2026-10-28: "label it simply Restream… it does look
// a little weird" next to the others.
// a little weird" next to the others.
-const MENU_NAME = { stream: 'Restream', bench: 'AI' };
+const MENU_NAME = { stream: 'Restream' };
const groupOf = (id) => (GROUPS.find(g => g.sites.includes(id)) || GROUPS[GROUPS.length - 1]).id;
const groupOf = (id) => (GROUPS.find(g => g.sites.includes(id)) || GROUPS[GROUPS.length - 1]).id;
+// Sites whose own domain keeps answering but that are a section of another site: in the menu, the footer, the home
+// pages and /sites they are not a tile of their own (owner, 2026-11-10). siteForHost still knows them.
+// media → Video (openvibe.media stays the API and the embed host)
+const MERGED_SITES = new Map([['media', 'media-hub']]);
+
// Products with a domain of their own that another service serves (its manifest lists the domain): in the menu
// Products with a domain of their own that another service serves (its manifest lists the domain): in the menu
-// while that service's site is open, right after it. They are not sites of their own (no ranking, no legal profile).
+// while that service's site is open, right after it. They are not sites of their own (no ranking, no legal profile),
+// but they are list entries: Pics and Download are what Video's drive shows on their own domains (owner, 2026-11-10).
const PRODUCTS = [
const PRODUCTS = [
{ id: 'pics', name: 'Pics', host: 'openvibe.pics', icon: 'pics', of: 'media-hub', tagline: 'Share pictures, fast and open' },
{ id: 'pics', name: 'Pics', host: 'openvibe.pics', icon: 'pics', of: 'media-hub', tagline: 'Share pictures, fast and open' },
{ id: 'download', name: 'Download', host: 'openvibe.download', icon: 'download', of: 'media-hub', tagline: 'Your files, shared on your terms' },
{ id: 'download', name: 'Download', host: 'openvibe.download', icon: 'download', of: 'media-hub', tagline: 'Your files, shared on your terms' },
- { id: 'homes', name: 'Homes', host: 'openvibe.homes', icon: 'homes', of: 'rent', tagline: 'Every home-for-sale site, one search' },
- // The move (2026-11-08): Bench's public pages are Report's AI section. The product keeps the old host for
- // redirects and links straight at the Report page; Report's manifest will name the host in the next Contracts
- // release, so the explicit `listed` flag is the bridge until then.
- { id: 'bench', name: 'AI', host: 'bench.openvibe.services', icon: 'bench', of: 'report', url: 'https://openvibe.report/ai', tagline: 'Which AI is best, and what it costs', listed: true },
].filter((p) => {
].filter((p) => {
- if (p.listed) return true;
const s = SITES.find(x => x.id === p.of); const m = s && contracts.services.get(s.service);
const s = SITES.find(x => x.id === p.of); const m = s && contracts.services.get(s.service);
return m && (m.domains || []).includes(p.host);
return m && (m.domains || []).includes(p.host);
});
});
+// Domains of a merged section (no tile): Homes is Rent's for-sale search, Bench is Report's AI section (its host
+// already redirects). They stay in the host map so a page or a sign-in return keeps naming its own place.
+const MERGED_PRODUCTS = [
+ { id: 'homes', name: 'Homes', host: 'openvibe.homes', icon: 'homes', of: 'rent' },
+ { id: 'bench', name: 'AI', host: 'bench.openvibe.services', icon: 'bench', of: 'report' },
+];
+
+// The list the menu, the footer, the home pages and /sites show: every site except a merged section, plus the
+// products above with a domain of their own (Pics, Download). Open sites in this list today: 55.
+const LISTED_SITES = SITES.filter((s) => !MERGED_SITES.has(s.id));
+const listed = (p) => {
+ const owner = SITES.find((s) => s.id === p.of);
+ return { ...p, service: p.of, what: p.tagline, status: owner ? owner.status : 'soon', state: owner ? owner.state : 'placeholder' };
+};
+const LISTED = LISTED_SITES.concat(PRODUCTS.map(listed));
// Manifest icon names the shared icon set draws differently.
// Manifest icon names the shared icon set draws differently.
const ICON_ALIAS = { 'media-hub': 'video' };
const ICON_ALIAS = { 'media-hub': 'video' };
@@ -120,10 +134,10 @@ function siteForHost(hostname) {
@@ -120,10 +134,10 @@ function siteForHost(hostname) {
if (byHost.has(h)) return byHost.get(h);
if (byHost.has(h)) return byHost.get(h);
// A product on its own domain belongs to the site that serves it (Bench's host is Report's AI section now).
// A product on its own domain belongs to the site that serves it (Bench's host is Report's AI section now).
// Before the subdomain pass, because services.openvibe.services would otherwise claim bench.openvibe.services.
// Before the subdomain pass, because services.openvibe.services would otherwise claim bench.openvibe.services.
- const product = PRODUCTS.find((p) => p.host === h || h.endsWith('.' + p.host));
+ const product = PRODUCTS.concat(MERGED_PRODUCTS).find((p) => p.host === h || h.endsWith('.' + p.host));
if (product) return SITES.find((s) => s.id === product.of) || null;
if (product) return SITES.find((s) => s.id === product.of) || null;
for (const s of SITES) if (h.endsWith('.' + s.host)) return s;
for (const s of SITES) if (h.endsWith('.' + s.host)) return s;
return null;
return null;
}
}
-module.exports = { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName };
+module.exports = { SITES, LISTED_SITES, LISTED, siteForHost, GROUPS, groupOf, PRODUCTS, MERGED_SITES, MERGED_PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName };
@@ -26,18 +26,20 @@ const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" dat
@@ -26,18 +26,20 @@ const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" dat
// The sites come from the frame's list (server/frame/sites.js): a site is open only when its service's
// The sites come from the frame's list (server/frame/sites.js): a site is open only when its service's
// public domain serves the service itself (Network's exposure overlay), so the home page, the navbar and
// public domain serves the service itself (Network's exposure overlay), so the home page, the navbar and
// "Opening next" always agree with what is actually running.
// "Opening next" always agree with what is actually running.
-const { SITES: frameSites, GROUPS, groupOf, PRODUCTS, fullName } = require('../frame/sites');
+const { LISTED, GROUPS, groupOf, fullName } = require('../frame/sites');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const social = require('openvibe-shared/social');
const social = require('openvibe-shared/social');
// OpenRestream is its own name, never "OpenVibe.OpenRestream" (the owner, 2026-10-28).
// OpenRestream is its own name, never "OpenVibe.OpenRestream" (the owner, 2026-10-28).
const siteName = (site) => (site.name.includes('.') ? site.name : fullName(site));
const siteName = (site) => (site.name.includes('.') ? site.name : fullName(site));
-const OPEN = frameSites.filter((site) => site.status === 'open' && site.id !== 'network');
-// How many sites there are: every open one, this front door included, the same count /sites shows (server/sites/page.js).
+const OPEN = LISTED.filter((site) => site.status === 'open' && site.id !== 'network');
+// How many places the network lists: every open entry, this front door included, the same count the menu, /sites and
+// the Frame's pulse/online answer (server/sites/page.js updates with it). Pics and Download count; Media, Homes and
+// Bench are merged sections, not entries.
// One number for the whole page: until 2026-11-06 the sites heading counted without the front door and the link under it
// One number for the whole page: until 2026-11-06 the sites heading counted without the front door and the link under it
// with it, so a phone showed "51 sites, one front door" above "All 52 sites" (the owner: "why isn't this count just
// with it, so a phone showed "51 sites, one front door" above "All 52 sites" (the owner: "why isn't this count just
// dynamic"). It always was computed; it now has one definition.
// dynamic"). It always was computed; it now has one definition.
-const SITE_COUNT = frameSites.filter((site) => site.status === 'open').length;
-const SOON = frameSites.filter((site) => site.status !== 'open');
+const SITE_COUNT = LISTED.filter((site) => site.status === 'open').length;
+const SOON = LISTED.filter((site) => site.status !== 'open');
const WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen', 'Twenty'];
const WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', 'Seventeen', 'Eighteen', 'Nineteen', 'Twenty'];
const inWords = (n) => WORDS[n] || String(n);
const inWords = (n) => WORDS[n] || String(n);
const ACCOUNT = [
const ACCOUNT = [
@@ -370,18 +372,12 @@ function body(catalog, act) {
@@ -370,18 +372,12 @@ function body(catalog, act) {
const popular = POPULAR.map((id) => byId.get(id)).filter(Boolean);
const popular = POPULAR.map((id) => byId.get(id)).filter(Boolean);
const fams = catalog.families.filter((f) => f.url);
const fams = catalog.families.filter((f) => f.url);
const count = (f) => f.count || catalog.tools.filter((t) => t.family === f.id).length;
const count = (f) => f.count || catalog.tools.filter((t) => t.family === f.id).length;
- // The service map: every open site under the launcher's groups (server/frame/sites.js GROUPS), so the 36 cards read
- // as six neighbourhoods, each with the products that live on another site's domain (openvibe.pics, …) beside it.
- // Every open site in its neighbourhood (server/frame/sites.js GROUPS), as a launcher under its short name (the domain
- // word: "Live", "Reviews"; the full name leads its one line): a phone shows the icons three or
- // four to a row, the way its home screen does; wider screens add each site's one line. The products that live on
- // another site's domain (openvibe.pics, …) are tiles of their neighbourhood too. /sites has the long descriptions.
+ // The service map: every open place under the launcher's groups (server/frame/sites.js GROUPS), so the cards read
+ // as the same eight groups, in the same order, as the menu. Pics and Download are list entries beside Video;
+ // Media, Homes and Bench are merged sections and have no tile. /sites has the long descriptions.
const groups = GROUPS.map((g) => ({
const groups = GROUPS.map((g) => ({
name: g.name,
name: g.name,
- items: [
- ...OPEN.filter((site) => groupOf(site.id) === g.id).map((site) => ({ icon: `ov:${site.icon}`, name: site.name, text: `${siteName(site)}: ${site.tagline || site.what}`, href: `https://${site.host}/` })),
- ...PRODUCTS.filter((x) => groupOf(x.id) === g.id).map((x) => ({ icon: `ov:${x.icon}`, name: x.name, text: x.tagline, href: x.url || `https://${x.host}/` })),
- ],
+ items: OPEN.filter((site) => groupOf(site.id) === g.id).map((site) => ({ icon: `ov:${site.icon}`, name: site.name, text: `${siteName(site)}: ${site.tagline || site.what}`, href: `https://${site.host}/` })),
})).filter((g) => g.items.length);
})).filter((g) => g.items.length);
const sites = showcase.apps({
const sites = showcase.apps({
id: 'network', title: sitesHeading(),
id: 'network', title: sitesHeading(),
@@ -10,10 +10,10 @@
@@ -10,10 +10,10 @@
* sites, still excluding what the person uses; when Report does not answer at all, it is the fixed starter list.
* sites, still excluding what the person uses; when Report does not answer at all, it is the fixed starter list.
* The answer says which it is: { source, basis, generated_at, items: [...] }.
* The answer says which it is: { source, basis, generated_at, items: [...] }.
*/
*/
-const { SITES, fullName } = require('../frame/sites');
+const { LISTED_SITES, fullName } = require('../frame/sites');
const DAY = 86_400_000;
const DAY = 86_400_000;
-const OPEN = SITES.filter((site) => site.status === 'open' && site.id !== 'network');
+const OPEN = LISTED_SITES.filter((site) => site.status === 'open' && site.id !== 'network');
const BY_SERVICE = new Map();
const BY_SERVICE = new Map();
for (const site of OPEN) { BY_SERVICE.set(site.service, site); BY_SERVICE.set(site.id, site); }
for (const site of OPEN) { BY_SERVICE.set(site.service, site); BY_SERVICE.set(site.id, site); }
// History's host-derived service name for a product whose site has another id.
// History's host-derived service name for a product whose site has another id.
@@ -1,28 +1,28 @@
@@ -1,28 +1,28 @@
'use strict';
'use strict';
-// /sites: the front door to the whole network, for people and for crawlers (SEO-9). Every open site is listed under
-// the same six neighbourhoods the home page uses (server/frame/sites.js GROUPS), with the site's own tagline, and
+// /sites: the front door to the whole network, for people and for crawlers (SEO-9). Every open entry is listed under
+// the same eight groups the home page and the menu use (server/frame/sites.js GROUPS), with its own tagline, and
// each site is anchored at its registry id (/sites#live) so it can be linked directly. One source: the registry the
// each site is anchored at its registry id (/sites#live) so it can be linked directly. One source: the registry the
// home reads, so the hub cannot disagree with the home, and a site that is not open is never listed.
// home reads, so the hub cannot disagree with the home, and a site that is not open is never listed.
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const icons = require('openvibe-shared/ov-icons');
const icons = require('openvibe-shared/ov-icons');
-const { SITES, GROUPS, groupOf, PRODUCTS, fullName } = require('../frame/sites');
+const { LISTED, GROUPS, groupOf, fullName } = require('../frame/sites');
const esc = seo.esc;
const esc = seo.esc;
const siteName = (site) => (site.name.includes('.') ? site.name : fullName(site));
const siteName = (site) => (site.name.includes('.') ? site.name : fullName(site));
// The one-line purpose, in the home's own words: the tagline it renders on every card (the llms.txt and ItemList
// The one-line purpose, in the home's own words: the tagline it renders on every card (the llms.txt and ItemList
// descriptions use `what`, the longer text).
// descriptions use `what`, the longer text).
const siteLine = (site) => site.tagline || site.what;
const siteLine = (site) => site.tagline || site.what;
-const openSites = (list = SITES) => list.filter((site) => site.status === 'open');
+const openSites = (list = LISTED) => list.filter((site) => site.status === 'open');
// The home counts the front door itself in "N sites, one account"; the hub lists it too (it is an open site, with the
// The home counts the front door itself in "N sites, one account"; the hub lists it too (it is an open site, with the
// account and themes behind every other one).
// account and themes behind every other one).
-const groupsOf = (sites) => GROUPS.map((group) => ({ group, sites: sites.filter((site) => groupOf(site.id) === group.id), also: PRODUCTS.filter((p) => groupOf(p.id) === group.id) })).filter((x) => x.sites.length);
+const groupsOf = (sites) => GROUPS.map((group) => ({ group, sites: sites.filter((site) => groupOf(site.id) === group.id) })).filter((x) => x.sites.length);
// The network's own share card, the same one the home pages carry (an indexable page without og:image is the one
// The network's own share card, the same one the home pages carry (an indexable page without og:image is the one
// thing the SEO audit still finds on this page).
// thing the SEO audit still finds on this page).
const shareCard = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';
const shareCard = require('openvibe-shared/social').url('network', 'https://openvibe.network/shared') || 'https://openvibe.network/assets/logo-512.png';
const card = (site) => `<a class="sc-card" id="${esc(site.id)}" href="https://${esc(site.host)}/"><span class="sc-ic sc-ic-ov"><span class="ov-icon sc-ovi" data-icon="${esc(site.icon)}" style="--ovi-size:40px" aria-hidden="true">${icons.svg(site.icon)}</span></span><span><b>${esc(siteName(site))}</b><small>${esc(siteLine(site))}</small></span></a>`;
const card = (site) => `<a class="sc-card" id="${esc(site.id)}" href="https://${esc(site.host)}/"><span class="sc-ic sc-ic-ov"><span class="ov-icon sc-ovi" data-icon="${esc(site.icon)}" style="--ovi-size:40px" aria-hidden="true">${icons.svg(site.icon)}</span></span><span><b>${esc(siteName(site))}</b><small>${esc(siteLine(site))}</small></span></a>`;
-function render({ releaseId = '', sites = SITES } = {}) {
+function render({ releaseId = '', sites = LISTED } = {}) {
const open = openSites(sites);
const open = openSites(sites);
const groups = groupsOf(open);
const groups = groupsOf(open);
const count = open.length;
const count = open.length;
@@ -30,9 +30,9 @@ function render({ releaseId = '', sites = SITES } = {}) {
@@ -30,9 +30,9 @@ function render({ releaseId = '', sites = SITES } = {}) {
const desc = `Every open OpenVibe site in one place, grouped by what people come to do, each with a link to its own home. One free account signs you in on all of them.`;
const desc = `Every open OpenVibe site in one place, grouped by what people come to do, each with a link to its own home. One free account signs you in on all of them.`;
const intro = 'Every open OpenVibe site, grouped by what people come to do; signing in once carries your name, theme and notifications to all of them.';
const intro = 'Every open OpenVibe site, grouped by what people come to do; signing in once carries your name, theme and notifications to all of them.';
const headings = new Map(groups.map(({ group }) => [group.id, `h-${group.id}`]));
const headings = new Map(groups.map(({ group }) => [group.id, `h-${group.id}`]));
- // The group anchors are prefixed: a group id ("games") can be a site id too, and one page cannot hold both.
+ // The group anchors are prefixed: a group id can match a site id ("tools"), and one page cannot hold both.
const jump = `<nav class="sites-jump" aria-labelledby="h-jump"><h2 id="h-jump">The groups</h2><ol>${groups.map(({ group, sites: list }) => `<li><a href="#group-${esc(group.id)}">${esc(group.name)}<small>${list.length}</small></a></li>`).join('')}</ol></nav>`;
const jump = `<nav class="sites-jump" aria-labelledby="h-jump"><h2 id="h-jump">The groups</h2><ol>${groups.map(({ group, sites: list }) => `<li><a href="#group-${esc(group.id)}">${esc(group.name)}<small>${list.length}</small></a></li>`).join('')}</ol></nav>`;
- const body = groups.map(({ group, sites: list, also }) => `<section class="sc-sec" id="group-${esc(group.id)}" aria-labelledby="${esc(headings.get(group.id))}"><h2 id="${esc(headings.get(group.id))}">${esc(group.name)}</h2><div class="sc-grid">${list.map(card).join('')}</div>${also.length ? `<p class="sites-also">Also here: ${also.map((p) => `<a href="${esc(p.url || `https://${p.host}/`)}">${esc(p.name || p.host)}</a> (${esc(p.tagline.toLowerCase())})`).join(', ')}</p>` : ''}</section>`).join('\n');
+ const body = groups.map(({ group, sites: list }) => `<section class="sc-sec" id="group-${esc(group.id)}" aria-labelledby="${esc(headings.get(group.id))}"><h2 id="${esc(headings.get(group.id))}">${esc(group.name)}</h2><div class="sc-grid">${list.map(card).join('')}</div></section>`).join('\n');
const ld = {
const ld = {
'@context': 'https://schema.org', '@type': 'CollectionPage',
'@context': 'https://schema.org', '@type': 'CollectionPage',
name: title, description: desc, url: 'https://openvibe.network/sites',
name: title, description: desc, url: 'https://openvibe.network/sites',
@@ -108,9 +108,6 @@ const CSS = `
@@ -108,9 +108,6 @@ const CSS = `
.sites-jump small{color:var(--text-muted);font-weight:700;font-size:12px;font-variant-numeric:tabular-nums}
.sites-jump small{color:var(--text-muted);font-weight:700;font-size:12px;font-variant-numeric:tabular-nums}
.sites .sc-sec:first-of-type{margin-top:40px}
.sites .sc-sec:first-of-type{margin-top:40px}
.sc-card[id]{scroll-margin-top:84px}
.sc-card[id]{scroll-margin-top:84px}
-.sites-also{margin:12px 0 0;font-size:13.5px;color:var(--text-secondary)}
-.sites-also a{color:var(--accent-light);font-weight:600;text-decoration:none}
-.sites-also a:hover{text-decoration:underline}
`;
`;
module.exports = { render, llmsText, siteLine, openSites, CSS };
module.exports = { render, llmsText, siteLine, openSites, CSS };
@@ -8,14 +8,14 @@ const express = require('express');
@@ -8,14 +8,14 @@ const express = require('express');
const http = require('http');
const http = require('http');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const { createFrameService } = require('../server/frame/service');
const { createFrameService } = require('../server/frame/service');
-const { SITES, siteForHost } = require('../server/frame/sites');
+const { LISTED_SITES, siteForHost } = require('../server/frame/sites');
const listen = (app) => new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
const listen = (app) => new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
const url = (srv, path) => `http://127.0.0.1:${srv.address().port}${path}`;
const url = (srv, path) => `http://127.0.0.1:${srv.address().port}${path}`;
(async () => {
(async () => {
const db = getDb();
const db = getDb();
- const openIds = SITES.filter((s) => s.status === 'open').map((s) => s.id);
+ const openIds = LISTED_SITES.filter((s) => s.status === 'open').map((s) => s.id);
for (let i = 1; i <= 5; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `menu${i}`, 'x');
for (let i = 1; i <= 5; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `menu${i}`, 'x');
const ins = db.prepare("INSERT INTO user_history (user_id, service, type, title, url) VALUES (?, ?, 'page', 't', ?)");
const ins = db.prepare("INSERT INTO user_history (user_id, service, type, title, url) VALUES (?, ?, 'page', 't', ?)");
const insOld = db.prepare("INSERT INTO user_history (user_id, service, type, title, url, created_at) VALUES (?, ?, 'page', 't', ?, datetime('now', '-31 days'))");
const insOld = db.prepare("INSERT INTO user_history (user_id, service, type, title, url, created_at) VALUES (?, ?, 'page', 't', ?, datetime('now', '-31 days'))");
@@ -10,7 +10,7 @@ const { createFrameService } = require('../server/frame/service');
@@ -10,7 +10,7 @@ const { createFrameService } = require('../server/frame/service');
(async () => {
(async () => {
const db = getDb();
const db = getDb();
const svc = await createFrameService(db, { services: {} }, null, { selfToken: () => 'test-token' });
const svc = await createFrameService(db, { services: {} }, null, { selfToken: () => 'test-token' });
- assert.deepStrictEqual(svc.ranking().order.slice(0, 7), ['live', 'tools', 'space', 'community', 'games', 'media', 'network'], 'cold start: the hand-set base order');
+ assert.deepStrictEqual(svc.ranking().order.slice(0, 6), ['live', 'tools', 'space', 'community', 'games', 'network'], 'cold start: the hand-set base order (Media is Video\'s section, not a tile)');
for (let i = 1; i <= 4; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `rank${i}`, 'x');
for (let i = 1; i <= 4; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `rank${i}`, 'x');
const day = "substring(ov_now(), 1, 10)";
const day = "substring(ov_now(), 1, 10)";
@@ -5,9 +5,7 @@ const http = require('http');
@@ -5,9 +5,7 @@ const http = require('http');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const { createFrameService, BANNED } = require('../server/frame/service');
const { createFrameService, BANNED } = require('../server/frame/service');
-const { siteForHost, GROUPS } = require('../server/frame/sites');
-const HAS_MAIL = !!contracts.services.get('mail');
-const HAS_GIT = !!contracts.services.get('git');
+const { siteForHost, GROUPS, LISTED } = require('../server/frame/sites');
(async () => {
(async () => {
const db = getDb();
const db = getDb();
@@ -41,26 +39,25 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
@@ -41,26 +39,25 @@ assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the
let p = svc.payloadFor('openvibe.media');
let p = svc.payloadFor('openvibe.media');
// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then
// Email (OpenVibe.Mail) joins the cold order after Chat once its manifest is in the pinned Contracts release; until then
// the test still follows what the installed manifests say.
// the test still follows what the installed manifests say.
-const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock', 'image'];
-if (HAS_MAIL) COLD_ORDER.splice(COLD_ORDER.indexOf('chat') + 1, 0, 'mail');
-if (HAS_GIT) COLD_ORDER.splice(COLD_ORDER.indexOf('events') + 1, 0, 'git');
+const COLD_ORDER = ['live', 'tools', 'space', 'community', 'games', 'network', 'chat', 'mail', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'git', 'search', 'run', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange', 'fun', 'cam', 'casino', 'tours', 'shoes', 'connect', 'computer', 'lock', 'image'];
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');
assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), COLD_ORDER, 'cold start follows the base order');
assert.ok(!p.nav.some(n => n.id === 'play'), 'Play is part of Games, not a menu entry of its own');
assert.ok(!p.nav.some(n => n.id === 'play'), 'Play is part of Games, not a menu entry of its own');
-// The launcher: every open site in one of the groups, and the products other services serve on their own domains
-// right after their site (Pics and Download after Video, Homes after Rent).
-assert.deepStrictEqual(p.groups.map(g => g.name), ['Streams & places', 'Games & laughs', 'News & answers', 'Make & build', 'Shop & swap', 'People & you', 'For developers'], 'the six neighbourhoods (plan 2026-10-28 §3.1), the building blocks last');
-assert.strictEqual(p.groups.find(g => g.id === 'build').name, 'For developers');
-assert.deepStrictEqual(GROUPS.find(g => g.id === 'people').sites.slice(0, 3), ['chat', 'mail', 'community'], 'Email sits with Chat in the talk group, before Community');
+assert.ok(!p.nav.some(n => ['media', 'homes', 'bench', 'website'].includes(n.id)), 'merged sections and the domain-only Website are not tiles');
+// The launcher: every open place in one of the eight groups (owner, 2026-11-10), and the products other services
+// serve on their own domains right after their site (Pics and Download after Video; Homes and Bench are sections).
+assert.deepStrictEqual(p.groups.map(g => g.name), ['Watch & stream', 'Talk & belong', 'Play', 'Read & ask', 'Shop & money', 'Free tools', 'Make with AI', 'For developers'], 'the eight groups in the owner\'s order');
+assert.deepStrictEqual(p.groups.map(g => g.id), ['watch', 'talk', 'play', 'read', 'shop', 'tools', 'make', 'build']);
+assert.deepStrictEqual(GROUPS.find(g => g.id === 'talk').sites, ['community', 'chat', 'tips', 'vip', 'mail', 'help', 'network'], 'Email sits with Chat in the talk group');
assert.ok(p.nav.every(n => p.groups.some(g => g.id === n.group)), 'every entry has a group');
assert.ok(p.nav.every(n => p.groups.some(g => g.id === n.group)), 'every entry has a group');
-assert.deepStrictEqual(p.nav.filter(n => n.of).map(n => [n.id, n.of, n.url]), [['pics', 'media-hub', 'https://openvibe.pics/'], ['download', 'media-hub', 'https://openvibe.download/'], ['homes', 'rent', 'https://openvibe.homes/'], ['bench', 'report', 'https://openvibe.report/ai']]);
+assert.deepStrictEqual(p.nav.filter(n => n.of).map(n => [n.id, n.of, n.url]), [['pics', 'media-hub', 'https://openvibe.pics/'], ['download', 'media-hub', 'https://openvibe.download/']]);
const at = (id) => p.nav.findIndex(n => n.id === id);
const at = (id) => p.nav.findIndex(n => n.id === id);
-assert.ok(at('pics') === at('media-hub') + 1 && at('homes') === at('rent') + 1 && at('bench') === at('report') + 1, 'a product follows its site');
-assert.deepStrictEqual([p.nav[at('media-hub')].icon, p.nav[at('media-hub')].group, p.nav[at('homes')].group, p.nav[at('tools')].group], ['video', 'streams', 'shop', 'make'], 'Video draws the video icon (there is no media-hub icon)');
-assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.nav[at(id)].group), ['build', 'make', 'build', 'build', 'make'], 'robots and coding agents for people; the agent engine, compute and storage for developers (plan L39: Actor is the engine Codes, Vibe and the Dock call)');
-// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).
-[['games', 'games'], ['connect', 'make'], ['image', 'make'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['mail', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games'], ['vision', 'answers'], ['exchange', 'build'], ['fun', 'games'], ['cam', 'streams'], ['bench', 'answers'], ['casino', 'games'], ['tours', 'streams'], ['shoes', 'shop']]
+assert.ok(at('pics') === at('media-hub') + 1 && at('download') === at('media-hub') + 2, 'Pics and Download follow their site');
+assert.deepStrictEqual([p.nav[at('media-hub')].icon, p.nav[at('media-hub')].group, p.nav[at('tools')].group], ['video', 'watch', 'tools'], 'Video draws the video icon (there is no media-hub icon)');
+assert.deepStrictEqual(['actor', 'bot', 'run', 'zone', 'codes'].map((id) => p.nav[at(id)].group), ['make', 'make', 'build', 'build', 'make'], 'robots and coding agents make with AI; compute and storage are for developers');
+// Every tile is in exactly one group, each group has at most seven tiles, and the merged sections still answer.
+[['games', 'play'], ['connect', 'make'], ['image', 'tools'], ['quest', 'play'], ['inventory', 'play'], ['watch', 'build'], ['download', 'watch'], ['search', 'read'], ['chat', 'talk'], ['mail', 'talk'], ['domains', 'tools'], ['report', 'read'], ['design', 'tools'], ['today', 'read'], ['surf', 'play'], ['meme', 'play'], ['vision', 'read'], ['exchange', 'build'], ['fun', 'play'], ['cam', 'watch'], ['casino', 'play'], ['tours', 'watch'], ['shoes', 'shop']]
.filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));
.filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));
-assert.strictEqual(p.nav.length, 56 + (HAS_MAIL ? 1 : 0) + (HAS_GIT ? 1 : 0)); // Computer and Locker since Contracts 0.347.0, Studio since 0.348.0 (2026-11-08).
+assert.strictEqual(p.nav.length, 55, '53 open sites plus Pics and Download');
assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');
assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
@@ -157,7 +154,7 @@ for (let i = 0; i < 6; i++) await ins.run(1, 'tools', 'dns');
@@ -157,7 +154,7 @@ for (let i = 0; i < 6; i++) await ins.run(1, 'tools', 'dns');
assert.equal(r.headers.get('access-control-allow-origin'), '*');
assert.equal(r.headers.get('access-control-allow-origin'), '*');
assert.match(r.headers.get('cache-control'), /max-age=30/);
assert.match(r.headers.get('cache-control'), /max-age=30/);
const j = await r.json();
const j = await r.json();
- assert.deepStrictEqual([j.live_now, j.watching, j.sites], [2, 7, pulseSvc.ranking().order.length]);
+ assert.deepStrictEqual([j.live_now, j.watching, j.sites], [2, 7, LISTED.filter((s) => s.status === 'open').length], 'the one open-place count (sites plus Pics and Download)');
assert.ok(j.views_7d > 0 && j.updated, 'last week\'s views from the ranking');
assert.ok(j.views_7d > 0 && j.updated, 'last week\'s views from the ranking');
liveList.close();
liveList.close();
const down = await (await fetch(`http://127.0.0.1:${srv.address().port}/api/frame/pulse`)).json();
const down = await (await fetch(`http://127.0.0.1:${srv.address().port}/api/frame/pulse`)).json();
@@ -6,7 +6,7 @@ const assert = require('assert');
@@ -6,7 +6,7 @@ const assert = require('assert');
const { parseHTML } = require('linkedom');
const { parseHTML } = require('linkedom');
const activity = require('../server/home/activity');
const activity = require('../server/home/activity');
const home = require('../server/home/render');
const home = require('../server/home/render');
-const sites = require('../server/frame/sites').SITES;
+const sites = require('../server/frame/sites').LISTED;
activity.setEnabled(false);
activity.setEnabled(false);
@@ -128,7 +128,7 @@ assert.ok(faqLd.mainEntity.length >= 5, 'the FAQ has its questions');
@@ -128,7 +128,7 @@ assert.ok(faqLd.mainEntity.length >= 5, 'the FAQ has its questions');
faqLd.mainEntity.forEach((q) => assert.ok([...parseHTML(html).document.querySelectorAll('#faq h3')].some((el) => el.textContent === q.name), `the FAQ shows "${q.name}"`));
faqLd.mainEntity.forEach((q) => assert.ok([...parseHTML(html).document.querySelectorAll('#faq h3')].some((el) => el.textContent === q.name), `the FAQ shows "${q.name}"`));
assert.ok(faqLd.mainEntity.some((q) => /separate account/.test(q.name)), "Contracts' network FAQ is included");
assert.ok(faqLd.mainEntity.some((q) => /separate account/.test(q.name)), "Contracts' network FAQ is included");
// The service map groups the sites the way the launcher does; numbers say only what was read.
// The service map groups the sites the way the launcher does; numbers say only what was read.
-assert.ok(/<h3 class="sc-apps-group">Streams & places<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make & build<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');
+assert.ok(/<h3 class="sc-apps-group">Watch & stream<\/h3>/.test(html) && /<h3 class="sc-apps-group">For developers<\/h3>/.test(html) && /<h3 class="sc-apps-group">Make with AI<\/h3>/.test(html) && html.includes('<ul class="sc-apps">'), 'the sites are grouped, as a launcher');
assert.ok(/<section class="net-num"/.test(html) && /sites, one account/.test(html), 'the numbers panel renders');
assert.ok(/<section class="net-num"/.test(html) && /sites, one account/.test(html), 'the numbers panel renders');
assert.ok(!/services up/.test(html) && !/accounts<\/span>/.test(html), 'numbers nobody read are left out');
assert.ok(!/services up/.test(html) && !/accounts<\/span>/.test(html), 'numbers nobody read are left out');
assert.ok(!/Latest recordings|New jobs|Fresh coupon codes/.test(html), 'an empty column is left out');
assert.ok(!/Latest recordings|New jobs|Fresh coupon codes/.test(html), 'an empty column is left out');
@@ -6,10 +6,10 @@ const assert = require('assert');
@@ -6,10 +6,10 @@ const assert = require('assert');
const activity = require('../server/home/activity');
const activity = require('../server/home/activity');
const home = require('../server/home/render');
const home = require('../server/home/render');
const sitesPage = require('../server/sites/page');
const sitesPage = require('../server/sites/page');
-const { SITES } = require('../server/frame/sites');
+const { LISTED } = require('../server/frame/sites');
activity.setEnabled(false);
activity.setEnabled(false);
-const expected = SITES.filter((s) => s.status === 'open').length;
+const expected = LISTED.filter((s) => s.status === 'open').length;
const { html } = home.render();
const { html } = home.render();
const text = html.replace(/<script[\s\S]*?<\/script>/g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
const text = html.replace(/<script[\s\S]*?<\/script>/g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
const WORDS = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty'];
const WORDS = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty'];
@@ -5,9 +5,9 @@ const assert = require('assert');
@@ -5,9 +5,9 @@ const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { SAME_AS, withSameAs } = require('../server/home/render');
const { SAME_AS, withSameAs } = require('../server/home/render');
-const { SITES } = require('../server/frame/sites');
+const { LISTED } = require('../server/frame/sites');
-const open = SITES.filter((s) => s.status === 'open' && s.id !== 'network');
+const open = LISTED.filter((s) => s.status === 'open' && s.id !== 'network');
assert.strictEqual(SAME_AS.length, open.length + 2, 'GitHub, Discord and every open site');
assert.strictEqual(SAME_AS.length, open.length + 2, 'GitHub, Discord and every open site');
assert.ok(SAME_AS.includes('https://github.com/OpenVibers'));
assert.ok(SAME_AS.includes('https://github.com/OpenVibers'));
const missing = open.filter((s) => !SAME_AS.includes(`https://${s.host}`)).map((s) => s.host);
const missing = open.filter((s) => !SAME_AS.includes(`https://${s.host}`)).map((s) => s.host);
@@ -93,8 +93,8 @@ const cache = require('openvibe-shared/cache-policy');
@@ -93,8 +93,8 @@ const cache = require('openvibe-shared/cache-policy');
// ── /sites: the hub of the whole network (SEO-9) ──
// ── /sites: the hub of the whole network (SEO-9) ──
const sitesPage = require('../server/sites/page');
const sitesPage = require('../server/sites/page');
- const { SITES } = require('../server/frame/sites');
- const openSites = SITES.filter((site) => site.status === 'open');
+ const { LISTED } = require('../server/frame/sites');
+ const openSites = LISTED.filter((site) => site.status === 'open');
const hub = await get('/sites');
const hub = await get('/sites');
assert.strictEqual(hub.status, 200, '/sites answers 200 (it was a 404 before SEO-9)');
assert.strictEqual(hub.status, 200, '/sites answers 200 (it was a 404 before SEO-9)');
assert.ok(hub.type.startsWith('text/html'), '/sites is HTML');
assert.ok(hub.type.startsWith('text/html'), '/sites is HTML');
@@ -130,7 +130,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -130,7 +130,7 @@ const cache = require('openvibe-shared/cache-policy');
assert.deepStrictEqual(pageIds.filter((id, i) => pageIds.indexOf(id) !== i), [], 'no id repeats on the page');
assert.deepStrictEqual(pageIds.filter((id, i) => pageIds.indexOf(id) !== i), [], 'no id repeats on the page');
// Only `open`: an entry that is not open never reaches the page or its ItemList.
// Only `open`: an entry that is not open never reaches the page or its ItemList.
const one = openSites[0];
const one = openSites[0];
- const closed = sitesPage.render({ sites: SITES.map((site) => (site.id === one.id ? { ...site, status: 'soon' } : site)) });
+ const closed = sitesPage.render({ sites: LISTED.map((site) => (site.id === one.id ? { ...site, status: 'soon' } : site)) });
assert.ok(!closed.includes(`id="${one.id}"`), 'a site that is not open is not anchored on the page');
assert.ok(!closed.includes(`id="${one.id}"`), 'a site that is not open is not anchored on the page');
assert.strictEqual(JSON.parse(closed.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]).mainEntity.numberOfItems, openSites.length - 1, 'and not in the ItemList');
assert.strictEqual(JSON.parse(closed.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]).mainEntity.numberOfItems, openSites.length - 1, 'and not in the ItemList');
// Findable: the sitemap lists it, and llms-full.txt carries its list.
// Findable: the sitemap lists it, and llms-full.txt carries its list.
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+'use strict';
+// The owner's one site list (2026-11-10, plan ask 274): eight groups, in this order, at most seven tiles each,
+// every site in exactly one of them (or a named merge with a domain that keeps answering). The same list feeds the
+// menu, its You-tab folders, the footer's Network column and the home pages' "More on OpenVibe".
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const {
+ SITES, LISTED, LISTED_SITES, GROUPS, groupOf, PRODUCTS, MERGED_SITES, MERGED_PRODUCTS,
+ MENU_LINE, MENU_NAME, siteForHost,
+} = require('../server/frame/sites');
+
+const LABELS = ['Watch & stream', 'Talk & belong', 'Play', 'Read & ask', 'Shop & money', 'Free tools', 'Make with AI', 'For developers'];
+assert.deepStrictEqual(GROUPS.map((g) => g.name), LABELS, 'the eight labels, in the order the menu, footer and homes show');
+assert.deepStrictEqual(GROUPS.map((g) => g.id), ['watch', 'talk', 'play', 'read', 'shop', 'tools', 'make', 'build'], 'the ids Contracts `site.neighbourhood` and Shared folders use');
+
+// At most seven tiles a group, and every listed place in exactly one group.
+for (const g of GROUPS) assert.ok(g.sites.length <= 7, `${g.name}: ${g.sites.length} tiles`);
+for (const s of LISTED) assert.strictEqual(GROUPS.filter((g) => g.sites.includes(s.id)).length, 1, `${s.id}: exactly one group`);
+assert.deepStrictEqual(GROUPS.map((g) => g.sites.length), [7, 7, 7, 7, 7, 7, 6, 7]);
+assert.strictEqual(LISTED.filter((s) => s.status === 'open').length, 55, 'the one number: 53 open sites plus Pics and Download');
+assert.deepStrictEqual(PRODUCTS.map((p) => LISTED.find((s) => s.id === p.id).status), ['open', 'open'], 'Pics and Download are open entries');
+
+// Every manifest site is in one group, or is one of the named merges: Media into Video, Bench into Report,
+// Homes into Rent (no manifest of its own) and Play into Games. `website` has no site and is in no list.
+const named = new Set([...MERGED_SITES.keys(), ...MERGED_PRODUCTS.map((p) => p.id), 'play']);
+const manifestSites = contracts.services.manifests.filter((m) => m.site);
+for (const m of manifestSites) {
+ const id = m.site.icon;
+ const grouped = GROUPS.some((g) => g.sites.includes(id));
+ assert.ok(grouped || named.has(id), `${id}: in a group or a named merge`);
+ assert.ok(!grouped || groupOf(id) === m.site.neighbourhood, `${id}: manifest neighbourhood ${m.site.neighbourhood} is its group`);
+}
+assert.ok(!GROUPS.some((g) => g.sites.includes('website')), 'Website (Space\'s second domain, no site) is in no list');
+assert.ok(!['media', 'homes', 'bench'].some((id) => GROUPS.some((g) => g.sites.includes(id))), 'the merged sections have no tile');
+
+// Names everywhere: Email, Restream, Studio and Locker; never the id or the old product name.
+const nameOf = (id) => MENU_NAME[id] || (LISTED.find((s) => s.id === id) || {}).name;
+assert.deepStrictEqual(['mail', 'stream', 'image', 'lock'].map(nameOf), ['Email', 'Restream', 'Studio', 'Locker']);
+assert.deepStrictEqual(['media', 'homes', 'bench', 'website'].filter((id) => LISTED.some((s) => s.id === id)), [], 'merged sections and Website are not listed');
+
+// Every tile keeps its one menu line.
+for (const s of LISTED) assert.ok(MENU_LINE[s.id] && MENU_LINE[s.id].length <= 28, `${s.id}: one line (${MENU_LINE[s.id]})`);
+
+// A merged section keeps its own domain answering; the frame gives it the section's home.
+assert.strictEqual(siteForHost('openvibe.homes').id, 'rent', 'Homes belongs to Rent');
+assert.strictEqual(siteForHost('bench.openvibe.services').id, 'report', 'Bench belongs to Report');
+assert.strictEqual(siteForHost('openvibe.media').id, 'media', 'Media still answers; it is Video\'s API, not a tile');
+assert.ok(SITES.some((s) => s.id === 'media'), 'Media stays a known host');
+
+console.log(`site groups: ${LISTED.filter((s) => s.status === 'open').length} open places in ${GROUPS.length} groups, at most seven a group, every manifest site placed`);