openvibers/openvibe.network Commits
Network: OpenVibe.Space signs in, publishes and is a site; contracts 0.110.0 (Space and AI live) (#75)
Changes
@@@ -48,7 +48,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA## Depends on--- `openvibe-contracts` v0.109.0 (registry manifests, capability checks), `openvibe-sdk` v0.28.0 (PostgreSQL++- `openvibe-contracts` v0.110.0 (registry manifests, capability checks), `openvibe-sdk` v0.28.0 (PostgreSQLand Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v2.10.1, pinned by release tarball- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`workflow), OpenVibe.Blog (the changelog feed it proxies)@@@ -21,7 +21,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.109.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1","pg": "^8.23.0",@@@ -1556,9 +1556,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.109.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.109.0",-- "integrity": "sha512-Ks2zLtR9XcN6ggr1APv93Ot8o/U1oAn8fOHNruYTDh1wjL/mKiwq5Lee8mx4rl6eb2A9zY8A45P/6edb3NNxgQ==",++ "version": "0.110.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",++ "integrity": "sha512-+65KnRBVOzPN+MKSdaKzbvop6CT/wr8ZrO9mrQrRDdprRDmL4+CCOnykKYFOiMWPtoYkXlYg+GXmwmOw7eBL/g==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -30,7 +30,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.109.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1","pg": "^8.23.0",@@@ -22,7 +22,7 @@<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png"><meta property="og:site_name" content="OpenVibe"><link rel="canonical" href="https://openvibe.network/">--<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://openvibe.network/#org","name":"OpenVibe","url":"https://openvibe.network/","logo":"https://openvibe.network/assets/logo-512.png","sameAs":["https://github.com/OpenVibers","https://discord.gg/M6MuRUaeJj","https://openvibe.live","https://openvibe.tools","https://openvibe.games","https://openvibe.media","https://openvibe.community"]},{"@type":"WebSite","@id":"https://openvibe.network/#site","url":"https://openvibe.network/","name":"OpenVibe.Network","description":"One account for the whole OpenVibe network: live streaming, tools, games, media and community.","publisher":{"@id":"https://openvibe.network/#org"}}]}</script>++<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://openvibe.network/#org","name":"OpenVibe","url":"https://openvibe.network/","logo":"https://openvibe.network/assets/logo-512.png","sameAs":["https://github.com/OpenVibers","https://discord.gg/M6MuRUaeJj","https://openvibe.live","https://openvibe.tools","https://openvibe.games","https://openvibe.media","https://openvibe.community","https://openvibe.space"]},{"@type":"WebSite","@id":"https://openvibe.network/#site","url":"https://openvibe.network/","name":"OpenVibe.Network","description":"One account for the whole OpenVibe network: live streaming, tools, games, media, forums and community.","publisher":{"@id":"https://openvibe.network/#org"}}]}</script><link rel="dns-prefetch" href="https://cdnjs.cloudflare.com"><!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.Paints the cached theme synchronously so the default palette never flashes. Reads@@@ -409,7 +409,7 @@ function ripple(btn, e) {// safe-redirect:begin// Where a sign-in may send the browser: a path on this site, this origin, or https on a zone// OpenVibe owns (server/auth/sso-owned.js). Never javascript:/data:, never another host.--const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];++const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.space', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];const OV_USER_CONTENT_ZONES = ['openvibe.host']; // tenant sites: only the apex is oursfunction safeReturnUrl(raw) {const s = String(raw == null ? '' : raw);@@@ -102,6 +102,7 @@ async function loadLinked() {games: { name: 'OpenVibe.Games', icon: 'fa-gamepad', url: 'https://openvibe.games', color: '#22c55e' },media: { name: 'OpenVibe.Media', icon: 'fa-photo-film', url: 'https://openvibe.media', color: '#22d3ee' },community: { name: 'OpenVibe.Community', icon: 'fa-people-group', url: 'https://openvibe.community', color: '#f59e0b' },++ space: { name: 'OpenVibe.Space', icon: 'fa-comments', url: 'https://openvibe.space', color: '#818cf8' },discord: { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj', color: '#5865f2' },network: { name: 'OpenVibe.Network', icon: 'fa-circle-nodes', url: '/', color: '#3b82f6' },};@@@ -66,7 +66,7 @@ li.now .dot i{animation:spin 1s linear infinite}// safe-redirect:begin// Where a sign-in may send the browser: a path on this site, this origin, or https on a zone// OpenVibe owns (server/auth/sso-owned.js). Never javascript:/data:, never another host.-- const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];++ const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.space', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];const OV_USER_CONTENT_ZONES = ['openvibe.host']; // tenant sites: only the apex is oursfunction safeNext(raw) {const s = String(raw == null ? '' : raw);@@@ -7,6 +7,7 @@ const cache = require('openvibe-shared/cache-policy');* chat.moderation.action chat moderation (Chat, and Live's chat bridge)* live.moderation.action Live's staff actions outside chat: bans, force-ended streams, admin deletions* community.moderation.action staff actions on other people's content in Community++ * space.moderation.action staff actions on forum threads and posts in Space* tips.interaction.moderated a paid message filtered, held, hidden or restored* billing.staff.action a staff money action in the Billing console* <service>.moderation.action common.moderation-action@1 (Contracts 0.53.0) from tools, games, wiki,@@@ -26,7 +27,7 @@ const { staffClaims } = require('../auth/staff-claims');// Producers whose event is common.moderation-action@1 (action, target { type, id, owner_subject }, actor_subject, reason, details).const COMMON_SERVICES = Object.freeze(['media', 'tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes']);--const TOPICS = Object.freeze(['chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',++const TOPICS = Object.freeze(['chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'space.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',...COMMON_SERVICES.map((svc) => `${svc}.moderation.action`)]);const str = (v, n) => (v == null || v === '' ? null : String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, n));const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});@@@ -48,9 +49,10 @@ function rowOf(event) {target_type: p.target_subject || p.target_user_id ? 'user' : null, target_id: str(p.target_subject || p.target_user_id, 128), target_subject: str(p.target_subject, 64),scope: str(`${p.scope_type || 'site'}${p.scope_id != null ? `:${p.scope_id}` : ''}`, 128), reason: str(d.reason, 500), details: json(d) };}-- case 'community.moderation.action': {++ case 'community.moderation.action':++ case 'space.moderation.action': {const t = obj(p.target);-- return { ...base, service: 'community', action: str(p.action, 64) || 'unknown', actor_subject: str(p.actor_subject || actorId, 64),++ return { ...base, service: event.event_type.split('.')[0], action: str(p.action, 64) || 'unknown', actor_subject: str(p.actor_subject || actorId, 64),target_type: str(t.type, 32), target_id: str(t.id, 200), target_subject: str(t.owner_subject, 64), scope: null, reason: str(p.reason, 500), details: json(p.details) };}case 'tips.interaction.moderated': {@@@ -40,6 +40,7 @@ const AREAS = [{ item: 'Moderation audit across services', where: [{ kind: 'tab', tab: 'modlog' }], service: 'network' },{ item: 'Chat logs and chat moderation', where: [{ kind: 'tab', tab: 'chat-logs' }], service: 'chat' },{ item: 'Community moderation', where: [{ kind: 'api', api: 'OpenVibe.Community staff routes', note: 'no console yet' }], service: 'community' },++ { item: 'Space forum moderation', where: [{ kind: 'api', api: 'OpenVibe.Space forum moderator routes', note: 'no console yet' }], service: 'space' },{ item: 'Payments, cashouts and the money freeze', where: [{ kind: 'url', url: 'https://billing.openvibe.network/', note: 'Billing console' }, { kind: 'tab', tab: 'cashouts' }, { kind: 'tab', tab: 'payments' }], service: 'billing' },{ item: 'Loyalty (OpenCoins, channel points)', where: [{ kind: 'api', api: 'live.loyalty user module; Network /internal/coins/*', note: 'summaries only; loyalty is never money' }], service: 'network' },{ item: 'Developer apps and trust (Codes)', where: [{ kind: 'url', url: 'https://openvibe.codes/staff' }], service: 'codes' },@@@ -5,7 +5,7 @@ const cache = require('openvibe-shared/cache-policy');** GET /api/v1/staff/capabilities the signed-in person's role, owner flag and staff capabilities* GET /api/v1/staff/moderators the network's staff (global moderators, admins, the owner) with-- * their capabilities; ?service=chat|live|community|pastes|calls keeps++ * their capabilities; ?service=chat|live|community|space|pastes|calls keeps* those who moderate it. For staff holding staff.moderation.logs, or a* service token with network.staff.read (Contracts 0.47.0).*@@@ -17,7 +17,7 @@ const { staffClaims } = require('../auth/staff-claims');const { isOwner } = require('../auth/owner-guard');// Which staff capability moderates a service; anything else is general content moderation.--const MODERATES = { chat: 'staff.moderation.chat', live: 'staff.moderation.channels', community: 'staff.moderation.discussions', pastes: 'staff.moderation.pastes', calls: 'staff.moderation.calls' };++const MODERATES = { chat: 'staff.moderation.chat', live: 'staff.moderation.channels', community: 'staff.moderation.discussions', space: 'staff.moderation.discussions', pastes: 'staff.moderation.pastes', calls: 'staff.moderation.calls' };async function staffList(db, { service = null } = {}) {const need = service ? (MODERATES[service] || 'staff.content.moderate') : null;@@@ -2,7 +2,7 @@// The zones OpenVibe owns. Cross-site trust (sso/check framing, FedCM relying parties, sign-in// handoff targets) is limited to these — never to openvibe.<anything>, which anyone can register.const OWNED_ZONES = [-- 'openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community',++ 'openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.space','openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews','openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons','openre.stream',@@@ -21,7 +21,7 @@ function isTrustedHost(hostname) {/** Which RP origins an OAuth client may exchange FedCM assertions for. */function clientOriginMatcher(client) {const id = String(client?.client_id || '');-- const zoneOf = { live: 'openvibe.live', tools: 'openvibe.tools', games: 'openvibe.games', media: 'openvibe.media', community: 'openvibe.community', network: 'openvibe.network' }[id];++ const zoneOf = { live: 'openvibe.live', tools: 'openvibe.tools', games: 'openvibe.games', media: 'openvibe.media', community: 'openvibe.community', space: 'openvibe.space', network: 'openvibe.network' }[id];let uris = [];try { uris = JSON.parse(client?.redirect_uris || '[]'); } catch { /* */ }const registered = new Set(uris.map(u => { try { return new URL(u).origin; } catch { return null; } }).filter(Boolean));@@@ -29,6 +29,9 @@ const DEFAULT_TARGETS = [{ id: 'community', name: 'OpenVibe.Community', origin: 'https://openvibe.community',login: 'https://openvibe.community/auth/login?silent=1&next={next}',logout: 'https://openvibe.community/auth/logout?next={next}' },++ { id: 'space', name: 'OpenVibe.Space', origin: 'https://openvibe.space',++ login: 'https://openvibe.space/auth/login?silent=1&next={next}',++ logout: 'https://openvibe.space/auth/logout?next={next}' },];/**@@@ -88,6 +88,7 @@ async function seedDb(db, { log = console } = {}) {{ client_id: 'games', name: 'OpenVibe.Games', redirect_uris: ['https://openvibe.games/auth/callback', 'https://play.openvibe.games/auth/callback'] },{ client_id: 'media', name: 'OpenVibe.Media', redirect_uris: ['https://openvibe.media/auth/callback'] },{ client_id: 'community', name: 'OpenVibe.Community', redirect_uris: ['https://openvibe.community/auth/callback'] },++ { client_id: 'space', name: 'OpenVibe.Space', redirect_uris: ['https://openvibe.space/auth/callback'] },// Waves 9-16 products with a signed-in UI. Create each with server/setup/service-principal.js// first (secret into its env file); this only adds the redirect URI to that client.{ client_id: 'tips', name: 'OpenVibe.Tips', redirect_uris: ['https://openvibe.tips/auth/callback'] },@@@ -128,6 +129,8 @@ async function seedDb(db, { log = console } = {}) {{ clientId: 'tools', extraUris: ['http://localhost:4001/auth/callback'] },{ clientId: 'games', extraUris: ['http://localhost:8000/auth/callback', 'http://localhost:5173/auth/callback'] },{ clientId: 'media', extraUris: ['http://localhost:4100/auth/callback'] },++ { clientId: 'community', extraUris: ['http://localhost:4200/auth/callback'] },++ { clientId: 'space', extraUris: ['http://localhost:4940/auth/callback'] },];for (const { clientId, extraUris } of localClients) {try { await mergeRedirectUris(db, clientId, extraUris, { log, local: true }); }@@@ -22,7 +22,7 @@ const toolsCatalog = require('../domains/catalog');const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;--const BASE_WEIGHT = { live: 6, tools: 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/*** Footer copy comes from OpenVibe.AI's network.site_copy workflow (roadmap Wave 13, ADR-015), called with a@@@ -16,7 +16,7 @@ const MAX_PER_USER = 2000; // oldest rows beyond this are trimmed on wrconst DEDUPE_WINDOW_MIN = 10; // same URL within this window bumps the entry instead of adding oneconst TYPES = new Set(['tool', 'stream', 'vod', 'clip', 'paste', 'game', 'page', 'post', 'account', 'theme', 'download', 'chat']);const SERVICE_LABELS = {-- live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', community: 'Community',++ live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', community: 'Community', space: 'Space',chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips',vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons', openre: 'OpenRe.Stream',};@@@ -44,6 +44,9 @@ const DEFAULT_GRANTS = [// pastes into Community on behalf of its users and its AI jobs.['community', 'identity.subject.resolve', SELF_AUDIENCE, []],['community', 'media.object.upload', 'openvibe.media', ['community']],++ // Plan T10: Space owns the forums and stores their attachments in its own Media namespace.++ ['space', 'identity.subject.resolve', SELF_AUDIENCE, []],++ ['space', 'media.object.upload', 'openvibe.media', ['space']],// Wave 11: Tools job results as Media objects; Wave 12: Games map-editor assets.// tools.* : developer projects' results go to tools.app.<project_id>[.sandbox] (WS-L task 5).['tools', 'media.object.upload', 'openvibe.media', ['tools', 'tools.*']],@@@ -76,7 +79,7 @@ const DEFAULT_GRANTS = [['games', 'mods.grant.manage', SELF_AUDIENCE, []],// Account export and deletion (ADR-033): the services that keep data about people push their export part and// confirm a deletion; the holders of each grant are the services Network waits for.-- ...['live', 'chat', 'community', 'media', 'games'].flatMap((svc) => [++ ...['live', 'chat', 'community', 'space', 'media', 'games'].flatMap((svc) => [[svc, 'network.account.export.contribute', SELF_AUDIENCE, []],[svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],]),@@@ -212,7 +215,7 @@ const DEFAULT_GRANTS = [// A streamer's daily AI-viewer budget is an AI quota on their attribution (live:user:<id>), which Live sets.['live', 'ai.quota.attribution.manage', 'openvibe.ai', []],// Wave 3: producers publish to OpenVibe.Events (their own source only, enforced by Events).-- ...['live', 'media', 'network', 'community', 'billing', 'chat', 'tools', 'games', 'search', 'sources', 'tips'].map(c => [c, 'events.event.publish', 'openvibe.events', []]),++ ...['live', 'media', 'network', 'community', 'space', 'billing', 'chat', 'tools', 'games', 'search', 'sources', 'tips'].map(c => [c, 'events.event.publish', 'openvibe.events', []]),// Wave 14: Search subscribes to <owner>.index_document.* deliveries.['search', 'events.subscription.manage', 'openvibe.events', []],// Plan T9: Search's saved-search notifier pushes one notification per new match through Network@@@ -225,6 +228,7 @@ const DEFAULT_GRANTS = [// VIP gates in products: Chat's subscriber badge, Community's and Blog's members-only content, Live's own checks.['chat', 'vip.entitlement.check', 'openvibe.vip', []],['community', 'vip.resource.policy.evaluate', 'openvibe.vip', []],++ ['space', 'vip.resource.policy.evaluate', 'openvibe.vip', []],['blog', 'vip.resource.policy.evaluate', 'openvibe.vip', []],// WS-K task 8: Wiki's VIP spaces and pages (Wiki server/integrations/vip.js).['wiki', 'vip.resource.policy.evaluate', 'openvibe.vip', []],@@@ -232,6 +236,8 @@ const DEFAULT_GRANTS = [['community', 'events.subscription.manage', 'openvibe.events', []],['community', 'events.event.read', 'openvibe.events', []],['community', 'events.event.publish', 'openvibe.events', []], // community.* events (Community server/events.js, 2026-09-24)++ ['space', 'events.subscription.manage', 'openvibe.events', []],++ ['space', 'events.event.read', 'openvibe.events', []],['billing', 'identity.subject.resolve', SELF_AUDIENCE, []],['chat', 'identity.subject.resolve', SELF_AUDIENCE, []],['live', 'identity.subject.resolve', SELF_AUDIENCE, []],@@@ -267,6 +273,7 @@ const DEFAULT_GRANTS = [// both also follow network.block.changed through their own Events subscriptions.['chat', 'network.blocks.read', SELF_AUDIENCE, []],['community', 'network.blocks.read', SELF_AUDIENCE, []],++ ['space', 'network.blocks.read', SELF_AUDIENCE, []],['live', 'chat.live_bridge.write', 'openvibe.chat', []],['live', 'chat.presence.read', 'openvibe.chat', []],// Plan T3: Live reads a channel's moderation settings, its moderators and emote count from Chat.@@@ -148,6 +148,7 @@ app.use(helmet({"https://openvibe.games", "https://play.openvibe.games","https://openvibe.media","https://openvibe.community",++ "https://openvibe.space","https://openvibe.blog","https://cloudflareinsights.com", // Cloudflare Web Analytics reports here (see script-src)// Release notifications: release-watch's EventSource on the Events realtime stream (openvibe-shared 1.17)@@@ -241,6 +242,7 @@ function buildAllowedOriginsSet() {'https://openvibe.games', 'https://www.openvibe.games', 'https://play.openvibe.games','https://openvibe.media','https://openvibe.community',++ 'https://openvibe.space',]) {origins.add(o);}@@@ -251,6 +253,8 @@ function buildAllowedOriginsSet() {'http://localhost:4000', 'http://127.0.0.1:4000', // network'http://localhost:4001', // tools gateway'http://localhost:4100', // media++ 'http://localhost:4200', // community++ 'http://localhost:4940', // space'http://localhost:8000', // games'http://localhost:5173', // games vite dev]) {@@@ -69,7 +69,7 @@ function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = glreturn { router: r, feed, queryOf };}--const SITES = ['live', 'network', 'tools', 'media', 'community', 'chat', 'games', 'blog', 'wiki', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes', 'host', 'ai', 'search', 'sources', 'events', 'billing', 'tips', 'vip', 'openre', 'bot', 'sites'];++const SITES = ['live', 'network', 'tools', 'media', 'community', 'space', 'chat', 'games', 'blog', 'wiki', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes', 'host', 'ai', 'search', 'sources', 'events', 'billing', 'tips', 'vip', 'openre', 'bot', 'sites'];const NAMES = { ai: 'AI', vip: 'VIP', openre: 'OpenRe' };const nameOf = (id) => NAMES[id] || (id ? id.charAt(0).toUpperCase() + id.slice(1) : '');@@@ -32,8 +32,8 @@ const exportDir = path.join(dir, 'account-exports');const log = console.log; console.log = () => {};const db = getDb();console.log = log;--// The five services that keep data about people are registered clients (production has them all).--for (const c of ['live', 'chat', 'community', 'media', 'games', 'tools']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);++// The services that keep data about people are registered clients (production has them all).++for (const c of ['live', 'chat', 'community', 'space', 'media', 'games', 'tools']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);const principals = require('../server/identity/principals');await principals.ensureSchema(db);require('../server/identity/follows').ensureSchema(db);@@@ -93,7 +93,7 @@ const server = http.createServer(app);// ── Export ──const expected = await accountData.expectedServices(db, 'network.account.export.contribute');-- assert.deepStrictEqual(expected, ['chat', 'community', 'games', 'live', 'media'], 'the holders of the contribute grant');++ assert.deepStrictEqual(expected, ['chat', 'community', 'games', 'live', 'media', 'space'], 'the holders of the contribute grant');let r = await call('POST', '/api/v1/account/export', tok(dana));assert.strictEqual(r.status, 201, JSON.stringify(r.body));assert.ok(validate('network.account-export@1', r.body).valid, JSON.stringify(validate('network.account-export@1', r.body).errors));@@@ -133,7 +133,7 @@ const server = http.createServer(app);r = await call('GET', `/api/v1/account/export/${exp}`, tok(dana));assert.strictEqual(r.body.status, 'partial');assert.ok(validate('network.account-export@1', r.body).valid);-- assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), ['network:received', 'chat:received', 'live:received', 'community:missing', 'games:missing', 'media:missing']);++ assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), ['network:received', 'chat:received', 'live:received', 'community:missing', 'games:missing', 'media:missing', 'space:missing']);assert.deepStrictEqual(notified.map((n) => n[0]), [dana.id], 'the person is told');r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));assert.strictEqual(r.status, 200);@@@ -141,6 +141,7 @@ const server = http.createServer(app);assert.match(r.headers.get('content-disposition'), /attachment; filename="openvibe-dana-\d{4}-\d\d-\d\d\.zip"/);const files = zip.read(r.buf);assert.ok(files['README.txt'].toString().includes('community: MISSING'));++ assert.ok(files['README.txt'].toString().includes('space: MISSING'));assert.deepStrictEqual(JSON.parse(files['live/follows.json']).map((f) => f.channel), ['xavier', 'yolanda'], 'the replacing part');const account = JSON.parse(files['network/account.json']);assert.deepStrictEqual([account.username, account.email, account.subject_id], ['dana', '[email protected]', dana.subject_id]);@@@ -158,7 +159,7 @@ const server = http.createServer(app);assert.ok(Number(r.headers.get('retry-after')) > 0);// Every expected service answers: ready at once.-- for (const s of ['community', 'games', 'media']) await db.prepare("UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = 'network.account.export.contribute'").run(s);++ for (const s of ['community', 'games', 'media', 'space']) await db.prepare("UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = 'network.account.export.contribute'").run(s);r = await call('POST', '/api/v1/account/export', tok(eve));const exp2 = r.body.export_id;await call('POST', `/internal/account-exports/${exp2}/parts`, svc('live'), part([{ name: 'profile.json', content: {} }], eve.subject_id));@@@ -263,7 +264,7 @@ const server = http.createServer(app);assert.strictEqual(r.status, 403);r = await call('GET', '/api/admin/account-deletions', tok(boss));const row = r.body.deletions.find((d) => d.deletion_id === del);-- assert.deepStrictEqual([row.confirmed, row.outstanding], [['chat'], ['community', 'games', 'live', 'media']]);++ assert.deepStrictEqual([row.confirmed, row.outstanding], [['chat'], ['community', 'games', 'live', 'media', 'space']]);// Staff cancel a scheduled deletion only with a reason.await call('POST', '/api/v1/account/deletion', tok(eve), { confirm_username: 'eve' });@@@ -57,7 +57,7 @@ const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHE(async () => {// The subscriptions this consumer is meant for, and nothing else.-- assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',++ assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'space.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',...['media', 'tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes'].map((svc) => `${svc}.moderation.action`),'tools.usage.recorded', 'events.usage.recorded']);assert.deepStrictEqual(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');@@@ -25,6 +25,8 @@ const tools = clientOriginMatcher({ client_id: 'tools', redirect_uris: '["https:assert.ok(tools('https://yt.openvibe.tools') && tools('https://openvibe.tools') && !tools('https://openvibe.live'), 'tools may exchange for any *.openvibe.tools origin only');const live = clientOriginMatcher({ client_id: 'live', redirect_uris: '["https://openvibe.live/api/auth/callback"]' });assert.ok(live('https://openvibe.live') && !live('https://openvibe.tools'));++const space = clientOriginMatcher({ client_id: 'space', redirect_uris: '["https://openvibe.space/auth/callback"]' });++assert.ok(space('https://openvibe.space') && !space('https://openvibe.community'));const a = fedcm.accountOf(users[7], 'https://openvibe.network');assert.deepStrictEqual(Object.keys(a).sort(), ['email', 'given_name', 'id', 'name', 'picture', 'username']);assert.strictEqual(a.picture, 'https://openvibe.network/data/avatars/7.png');@@@ -14,7 +14,9 @@ assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');assert.equal(siteForHost('evil.example'), null);assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');let p = svc.payloadFor('openvibe.media');--assert.deepEqual(p.nav.map(n => n.id), ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'codes', 'blog', 'wiki', 'host', 'bot'], 'cold start follows the base order');++assert.deepEqual(p.nav.map(n => n.id), ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'codes', 'blog', 'wiki', 'host', 'bot'], 'cold start follows the base order');++assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'the forum goes to Space');++assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community remains for pastes and Pulse');assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a site never recommends itself');assert.ok(p.soon.length >= 9, 'every planned site still listed as soon (chat launched 2026-09-24)');@@@ -20,6 +20,7 @@ const db = getDb();console.log = log;const principals = require('../server/identity/principals');const grants = require('../server/identity/grants-admin');++assert.strictEqual(require('../server/admin/staff-api').MODERATES.space, 'staff.moderation.discussions');for (const c of ['live', 'media']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('thirdparty', 'x', 'x', '[]', 0) ON CONFLICT DO NOTHING").run();await principals.ensureSchema(db);@@@ -15,12 +15,15 @@ assert.strictEqual(allowedUrl('https://evil.example.com/'), null, 'off-network Uassert.strictEqual(allowedUrl('javascript:alert(1)'), null);assert.deepStrictEqual(serviceFromUrl('https://pastes.openvibe.tools/p/x'), { service: 'tools', sub: 'pastes' });assert.deepStrictEqual(serviceFromUrl('https://openvibe.live/@someone'), { service: 'live', sub: null });++assert.deepStrictEqual(serviceFromUrl('https://openvibe.space/s/general'), { service: 'space', sub: null });assert.deepStrictEqual(serviceFromUrl('https://ingest.openre.stream/'), { service: 'openre', sub: 'ingest' });const t = ssoTargets({});--assert.ok(t.find(x => x.id === 'live') && t.find(x => x.id === 'tools') && t.find(x => x.id === 'community'), 'default targets');++assert.ok(t.find(x => x.id === 'live') && t.find(x => x.id === 'tools') && t.find(x => x.id === 'community') && t.find(x => x.id === 'space'), 'default targets');++assert.strictEqual(t.find(x => x.id === 'space').login, 'https://openvibe.space/auth/login?silent=1&next={next}');assert.ok(t.every(x => x.login.includes('{next}')), 'every login hop carries the continuation');--assert.deepStrictEqual(ssoTargets({ OV_SSO_TARGETS_DISABLED: 'games, community' }).map(x => x.id), ['live', 'tools'], 'disabled targets are dropped');++assert.deepStrictEqual(ssoTargets({ OV_SSO_TARGETS_DISABLED: 'games, community' }).map(x => x.id), ['live', 'tools', 'space'], 'disabled targets are dropped');++assert.strictEqual(safeNext('https://openvibe.space/s/general'), 'https://openvibe.space/s/general');assert.strictEqual(safeNext('https://openvibe.live/@x?y=1'), 'https://openvibe.live/@x?y=1');assert.strictEqual(safeNext('https://phish.example/'), '/', 'foreign hosts fall back to /');assert.strictEqual(safeNext('//evil'), '/');@@@ -28,33 +28,36 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: idconst chat = ev('chat.moderation.action', { action_id: 5, action_type: 'ban', scope_type: 'channel', scope_id: '327', actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: 'spam' } });const community = ev('community.moderation.action', { action: 'paste.deleted', target: { type: 'paste', id: 'k3f9Qa', owner_subject: TARGET }, actor_subject: MOD, reason: 'doxxing', details: {} });++ const space = ev('space.moderation.action', { action: 'thread.hidden', target: { type: 'thread', id: 'welcome', owner_subject: TARGET }, actor_subject: MOD, reason: 'spam', details: {} });const tips = ev('tips.interaction.moderated', { interaction_id: 'tint_01JAB3C4D5E6F7G8H9J0K1MNPQ', creator: { type: 'user', id: TARGET }, action: 'hidden', by: 'moderator', moderation_state: 'hidden', cancelled_effects: ['tts'] }, { type: 'service', id: 'tips' });const live = ev('live.moderation.action', { action_id: 9, action_type: 'site_ban', scope_type: 'site', scope_id: null, actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: '=HYPERLINK("x")' } });const billing = ev('billing.staff.action', { audit_id: 'sa_01JAB3C4D5E6F7G8H9J0K1MNPQ', action: 'cashout.approved', outcome: 'done', target: { type: 'cashout', id: 'co_1' }, reason: null, request_id: null, detail: { amount: 5 } });-- for (const e of [chat, community, tips, billing, live]) assert.strictEqual((await consumer.apply(e)).outcome, 'recorded', e.event_type);++ for (const e of [chat, community, space, tips, billing, live]) assert.strictEqual((await consumer.apply(e)).outcome, 'recorded', e.event_type);assert.strictEqual((await consumer.apply(chat)).duplicate, true, 'a redelivery records nothing twice');assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM notifications').get()).n, 0, 'audit events notify nobody');const all = await audit.list({});-- assert.strictEqual(all.items.length, 5);++ assert.strictEqual(all.items.length, 6);const liveRow = all.items.find((r) => r.service === 'live');assert.deepStrictEqual([liveRow.action, liveRow.scope, liveRow.target_subject], ['site_ban', 'site', TARGET]);assert.strictEqual((await audit.list({ action: 'site_ban' })).items.length, 1);assert.strictEqual((await audit.list({ since: '2000-01-01', until: '2001-01-01' })).items.length, 0);-- assert.strictEqual((await audit.list({ since: '2000-01-01' })).items.length, 5);++ assert.strictEqual((await audit.list({ since: '2000-01-01' })).items.length, 6);const csv = require('../server/admin/moderation-audit').toCsv(all.items);assert.ok(csv.startsWith('id,occurred_at,service,action,'));assert.ok(csv.includes(`"'=HYPERLINK(""x"")"`), 'a formula-looking cell is text');-- assert.strictEqual(csv.trim().split('\r\n').length, 6);++ assert.strictEqual(csv.trim().split('\r\n').length, 7);const byService = Object.fromEntries(all.items.map((r) => [r.service, r]));assert.deepStrictEqual([byService.chat.action, byService.chat.scope, byService.chat.target_subject, byService.chat.reason], ['ban', 'channel:327', TARGET, 'spam']);assert.deepStrictEqual([byService.community.action, byService.community.target_type, byService.community.target_id, byService.community.reason], ['paste.deleted', 'paste', 'k3f9Qa', 'doxxing']);++ assert.deepStrictEqual([byService.space.action, byService.space.target_type, byService.space.target_id, byService.space.reason], ['thread.hidden', 'thread', 'welcome', 'spam']);assert.strictEqual(byService.tips.action, 'interaction.hidden');assert.strictEqual(byService.billing.actor_subject, MOD);assert.strictEqual((await audit.list({ service: 'community' })).items.length, 1);-- assert.strictEqual((await audit.list({ actor: MOD })).items.length, 4, 'tips carries no moderator identity');-- assert.strictEqual((await audit.list({ target: TARGET })).items.length, 4);++ assert.strictEqual((await audit.list({ service: 'space' })).items.length, 1);++ assert.strictEqual((await audit.list({ actor: MOD })).items.length, 5, 'tips carries no moderator identity');++ assert.strictEqual((await audit.list({ target: TARGET })).items.length, 5);const page = await audit.list({ limit: 2 });assert.strictEqual(page.items.length, 2);assert.strictEqual((await audit.list({ limit: 2, before: page.next })).items.length, 2, 'paged by id');@@@ -36,4 +36,6 @@ const pay = out.flatMap((a) => a.items).find((i) => /Payments/.test(i.item));assert.deepStrictEqual([pay.status, pay.console], ['ready', true]);const community = out.flatMap((a) => a.items).find((i) => /Community moderation/.test(i.item));assert.deepStrictEqual([community.status, community.console], ['unknown', false], 'a gap is shown as a gap');++const space = out.flatMap((a) => a.items).find((i) => /Space forum moderation/.test(i.item));++assert.deepStrictEqual([space.service, space.status, space.console], ['space', 'unknown', false]);console.log(`operator checklist: all checks passed (${items} items)`);@@@ -17,6 +17,11 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principals-'));const log = console.log; console.log = () => {};const db = getDb();console.log = log;++const spaceClient = await db.prepare("SELECT name, redirect_uris, is_first_party FROM oauth_clients WHERE client_id = 'space'").get();++assert.strictEqual(spaceClient.name, 'OpenVibe.Space');++assert.strictEqual(spaceClient.is_first_party, 1);++assert.ok(JSON.parse(spaceClient.redirect_uris).includes('https://openvibe.space/auth/callback'));++assert.ok(JSON.parse(spaceClient.redirect_uris).includes('http://localhost:4940/auth/callback'));await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();await db.prepare("UPDATE oauth_clients SET client_secret = 'media-secret' WHERE client_id = 'media'").run();@@@ -161,6 +166,10 @@ const server = http.createServer(app);const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });assert.strictEqual(com.status, 200, JSON.stringify(com.body));assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, account export and deletion, platform blocks, plus its community.profile module');++ await db.prepare("UPDATE oauth_clients SET client_secret = 'space-secret' WHERE client_id = 'space'").run();++ const spaceNetwork = await token({ client_id: 'space', client_secret: 'space-secret', audience: 'openvibe.network' });++ assert.strictEqual(spaceNetwork.status, 200, JSON.stringify(spaceNetwork.body));++ assert.deepStrictEqual(spaceNetwork.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read'], 'Space has forum identity, account data and block grants, with no Community profile module');r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${com.body.access_token}` });assert.strictEqual(r.status, 200); assert.strictEqual(r.body.results['7'].username, 'payee');// Bot adds an operator by @username: the same projection, any case, a leading @ ignored; an unknown name is a 404.@@@ -182,6 +191,13 @@ const server = http.createServer(app);assert.strictEqual(r.status, 403, 'Media holds no coin capability');const media = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.media' });assert.strictEqual(media.body.scope, 'media.object.upload', 'community may upload screenshot bytes to Media');++ for (const [audience, scope] of [['openvibe.media', 'media.object.upload'], ['openvibe.vip', 'vip.resource.policy.evaluate'], ['openvibe.events', 'events.event.publish events.event.read events.subscription.manage']]) {++ const response = await token({ client_id: 'space', client_secret: 'space-secret', audience });++ assert.strictEqual(response.status, 200, JSON.stringify(response.body));++ assert.strictEqual(response.body.scope, scope, `Space's ${audience} grants`);++ }++ const spaceUpload = await require('../server/identity/principals').grantsFor(db, 'space', 'openvibe.media');++ assert.deepStrictEqual(spaceUpload.find(g => g.capability === 'media.object.upload').namespaces, ['space']);const cm = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.community' });assert.deepStrictEqual(cm.body.scope.split(' '), ['community.comment.moderate', 'community.comment.write', 'community.paste.create', 'community.paste.moderate', 'community.paste.write', 'community.pulse.write']);const tl = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.tools' });@@@ -6,6 +6,7 @@ const http = require('http');const express = require('express');const contracts = require('openvibe-contracts');const { createEcosystemRegistry } = require('../server/registry/ecosystem');++assert.strictEqual(require('../server/registry/ecosystem').INTERNAL.space, 'http://127.0.0.1:4940');(async () => {// A running service answers readiness in the openvibe-shared/ready shape with a check that passed (liveness@@@ -29,6 +30,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');let r = await get('/.well-known/openvibe');assert.strictEqual(r.body.jwks_uri, 'https://openvibe.network/api/.well-known/jwks');assert.ok(r.body.services.some(s => s.id === 'community'));++ assert.ok(r.body.services.some(s => s.id === 'space' && s.origin === 'https://openvibe.space'));assert.strictEqual(r.cors, '*');r = await get('/api/v1/registry/services');@@@ -77,9 +79,10 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');assert.strictEqual(inCat('events'), 'platform'); assert.strictEqual(inCat('ai'), 'platform'); assert.strictEqual(inCat('sources'), 'platform');assert.strictEqual(inCat('sdk'), 'library'); assert.strictEqual(inCat('contracts'), 'library');assert.strictEqual(inCat('examples'), 'repository');-- // 'planned' = exposure placeholder: Contracts 0.83.0's media-hub and space, v0.87-v0.90's zone (charter only, nothing runs).-- // Watch left it in v0.108.0, Run and Services in v0.109.0 (they run, internal).-- assert.deepStrictEqual(cats.planned.services.map(x => x.id).sort(), ['media-hub', 'space', 'zone']);++ // 'planned' = exposure placeholder: Contracts 0.83.0's media-hub, v0.87-v0.90's zone (charter only, nothing runs).++ // Watch left it in v0.108.0, Run and Services in v0.109.0 (they run, internal); Space in v0.110.0 (live, a site).++ assert.strictEqual(inCat('space'), 'site');++ assert.deepStrictEqual(cats.planned.services.map(x => x.id).sort(), ['media-hub', 'zone']);const liveRow = cats.site.services.find(x => x.id === 'live');assert.strictEqual(liveRow.runtime, 'down'); assert.ok(liveRow.checked_at, 'rows carry the last check');r = await get('/api/v1/registry/categories/library');@@@ -18,8 +18,8 @@ const PINNED = {network: 'live', live: 'live', tools: 'live', media: 'live', games: 'live', community: 'live', events: 'live',billing: 'live', codes: 'live', blog: 'live', wiki: 'live', sites: 'live',news: 'internal', reviews: 'internal', deals: 'internal', coupons: 'internal', trade: 'internal', host: 'live',-- tips: 'internal', vip: 'internal', openre: 'internal', search: 'live', sources: 'internal', chat: 'live', 'media-hub': 'placeholder', space: 'placeholder',-- ai: 'internal',++ tips: 'internal', vip: 'internal', openre: 'internal', search: 'live', sources: 'internal', chat: 'live', 'media-hub': 'placeholder', space: 'live', // the forum, live since 2026-10-07 (contracts 0.110.0)++ ai: 'live', // the public home and developer-app API at ai.openvibe.services since 2026-10-07 (contracts 0.110.0)// openvibe-contracts v0.86-v0.90: Bot runs (its own exposure row); Zone is a charter only.bot: 'live', zone: 'placeholder',run: 'internal', // OpenVibe.Run runs on loopback since 2026-10-07 (contracts 0.109.0)@@@ -109,12 +109,12 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], chassert.strictEqual(sBy.live.origin, 'https://openvibe.live');assert.strictEqual(sBy.shared.label, `not running (library, released v${require('openvibe-shared/package.json').version})`);// Contracts 0.83.0 added two placeholder services (media-hub, space), v0.86-v0.90 Bot (live) and two more (zone, run),-- // v0.95.0 Services; none is retired-- assert.deepStrictEqual(st.exposure_summary, { live: 16, internal: 13, library: 4, repository: 1, placeholder: 3, retired: 0 });++ // v0.95.0 Services; Space and AI went live in v0.110.0; none is retired++ assert.deepStrictEqual(st.exposure_summary, { live: 18, internal: 12, library: 4, repository: 1, placeholder: 2, retired: 0 });// The page: no row for a service whose domain serves a placeholder shows a bare "Up".const html = await fetch(base + '/status').then(x => x.text());-- for (const id of PLACEHOLDER_DOMAINS.concat(['ai'])) {++ for (const id of PLACEHOLDER_DOMAINS) {const row = html.match(new RegExp(`<tr id="svc-${id}">[\\s\\S]*?</tr>`))[0];assert.ok(!/>Up<\/span>/.test(row), `${id} row must not read a bare Up`);assert.ok(row.includes('Up · loopback only'), `${id} row says loopback only`);@@@ -122,10 +122,11 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch}assert.ok(/<tr id="svc-news">[\s\S]*?openvibe\.news: not this service yet/.test(html));assert.ok(/<tr id="svc-live">[\s\S]*?>Up<\/span>/.test(html), 'a public service still reads Up');++ assert.ok(/<tr id="svc-ai">[\s\S]*?>Up<\/span>/.test(html), 'AI serves its own public origin since contracts 0.110.0');// 3. The Frame: the nav lists only sites whose domain serves the service; the rest are soon with a reason.const open = SITES.filter(s => s.status === 'open').map(s => s.id);-- assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'codes', 'blog', 'wiki', 'host', 'bot']); // bot: a site since openvibe-contracts 0.106.0; host: live since 0.107.0 (Stage B launched 2026-10-07)++ assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'codes', 'blog', 'wiki', 'host', 'space', 'bot']); // by site position; bot: a site since openvibe-contracts 0.106.0; host: live since 0.107.0; space: live since 0.110.0 (the forum, 2026-10-07)for (const s of SITES) {assert.ok(s.service && PINNED[s.service], `${s.id} names its service`);assert.strictEqual(s.status === 'open', PINNED[s.service] === 'live', `${s.id} nav status follows exposure`);@@@ -32,6 +32,7 @@ const BAD = [const GOOD = [['/', '/'], ['/my#linked', '/my#linked'], ['/admin?tab=users', '/admin?tab=users'],['https://openvibe.live/@x?y=1', 'https://openvibe.live/@x?y=1'],++ ['https://openvibe.space/s/general', 'https://openvibe.space/s/general'],['https://json.openvibe.tools/', 'https://json.openvibe.tools/'],['https://openvibe.network/admin', 'https://openvibe.network/admin'],['https://ingest.openre.stream/', 'https://ingest.openre.stream/'],@@@ -9,6 +9,7 @@ const cookieParser = require('cookie-parser');const { allowedOrigin, renderCheck, createSsoCheckRoute } = require('../server/auth/sso-check');assert.strictEqual(allowedOrigin('https://openvibe.tools'), 'https://openvibe.tools');++assert.strictEqual(allowedOrigin('https://openvibe.space'), 'https://openvibe.space');assert.strictEqual(allowedOrigin('https://json.openvibe.tools/'), 'https://json.openvibe.tools');assert.strictEqual(allowedOrigin('https://play.openvibe.games'), 'https://play.openvibe.games');assert.strictEqual(allowedOrigin('https://ingest.openre.stream'), 'https://ingest.openre.stream');@@@ -45,6 +45,8 @@ const publicCors = require('../server/public-cors');assert.strictEqual(page.split('class="up-sites"').length - 1, 1, 'one site filter');assert.ok(page.includes('<a href="/updates?site=bot">Bot</a>'), 'Bot is a site');assert.strictEqual(serviceFor('bot'), 'bot');++ assert.strictEqual(serviceFor('openvibe.space'), 'space');++ assert.ok(page.includes('<a href="/updates?site=space">Space</a>'));const one = await fetch(`${base}/updates?site=openvibe.wiki`);assert.strictEqual(one.headers.get('x-robots-tag'), 'noindex, follow');assert.ok((await one.text()).includes('What shipped on OpenVibe.Wiki'));
@@ -48,7 +48,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -48,7 +48,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
## Depends on
## Depends on
-- `openvibe-contracts` v0.109.0 (registry manifests, capability checks), `openvibe-sdk` v0.28.0 (PostgreSQL
+- `openvibe-contracts` v0.110.0 (registry manifests, capability checks), `openvibe-sdk` v0.28.0 (PostgreSQL
and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v2.10.1, pinned by release tarball
and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v2.10.1, pinned by release tarball
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
workflow), OpenVibe.Blog (the changelog feed it proxies)
workflow), OpenVibe.Blog (the changelog feed it proxies)
@@ -21,7 +21,7 @@
@@ -21,7 +21,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.109.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1556,9 +1556,9 @@
@@ -1556,9 +1556,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.109.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.109.0",
- "integrity": "sha512-Ks2zLtR9XcN6ggr1APv93Ot8o/U1oAn8fOHNruYTDh1wjL/mKiwq5Lee8mx4rl6eb2A9zY8A45P/6edb3NNxgQ==",
+ "version": "0.110.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",
+ "integrity": "sha512-+65KnRBVOzPN+MKSdaKzbvop6CT/wr8ZrO9mrQrRDdprRDmL4+CCOnykKYFOiMWPtoYkXlYg+GXmwmOw7eBL/g==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -30,7 +30,7 @@
@@ -30,7 +30,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.109.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta name="twitter:image" content="https://openvibe.network/assets/logo-512.png">
<meta property="og:site_name" content="OpenVibe">
<meta property="og:site_name" content="OpenVibe">
<link rel="canonical" href="https://openvibe.network/">
<link rel="canonical" href="https://openvibe.network/">
-<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://openvibe.network/#org","name":"OpenVibe","url":"https://openvibe.network/","logo":"https://openvibe.network/assets/logo-512.png","sameAs":["https://github.com/OpenVibers","https://discord.gg/M6MuRUaeJj","https://openvibe.live","https://openvibe.tools","https://openvibe.games","https://openvibe.media","https://openvibe.community"]},{"@type":"WebSite","@id":"https://openvibe.network/#site","url":"https://openvibe.network/","name":"OpenVibe.Network","description":"One account for the whole OpenVibe network: live streaming, tools, games, media and community.","publisher":{"@id":"https://openvibe.network/#org"}}]}</script>
+<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://openvibe.network/#org","name":"OpenVibe","url":"https://openvibe.network/","logo":"https://openvibe.network/assets/logo-512.png","sameAs":["https://github.com/OpenVibers","https://discord.gg/M6MuRUaeJj","https://openvibe.live","https://openvibe.tools","https://openvibe.games","https://openvibe.media","https://openvibe.community","https://openvibe.space"]},{"@type":"WebSite","@id":"https://openvibe.network/#site","url":"https://openvibe.network/","name":"OpenVibe.Network","description":"One account for the whole OpenVibe network: live streaming, tools, games, media, forums and community.","publisher":{"@id":"https://openvibe.network/#org"}}]}</script>
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
Paints the cached theme synchronously so the default palette never flashes. Reads
Paints the cached theme synchronously so the default palette never flashes. Reads
@@ -409,7 +409,7 @@ function ripple(btn, e) {
@@ -409,7 +409,7 @@ function ripple(btn, e) {
// safe-redirect:begin
// safe-redirect:begin
// Where a sign-in may send the browser: a path on this site, this origin, or https on a zone
// Where a sign-in may send the browser: a path on this site, this origin, or https on a zone
// OpenVibe owns (server/auth/sso-owned.js). Never javascript:/data:, never another host.
// OpenVibe owns (server/auth/sso-owned.js). Never javascript:/data:, never another host.
-const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];
+const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.space', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];
const OV_USER_CONTENT_ZONES = ['openvibe.host']; // tenant sites: only the apex is ours
const OV_USER_CONTENT_ZONES = ['openvibe.host']; // tenant sites: only the apex is ours
function safeReturnUrl(raw) {
function safeReturnUrl(raw) {
const s = String(raw == null ? '' : raw);
const s = String(raw == null ? '' : raw);
@@ -102,6 +102,7 @@ async function loadLinked() {
@@ -102,6 +102,7 @@ async function loadLinked() {
games: { name: 'OpenVibe.Games', icon: 'fa-gamepad', url: 'https://openvibe.games', color: '#22c55e' },
games: { name: 'OpenVibe.Games', icon: 'fa-gamepad', url: 'https://openvibe.games', color: '#22c55e' },
media: { name: 'OpenVibe.Media', icon: 'fa-photo-film', url: 'https://openvibe.media', color: '#22d3ee' },
media: { name: 'OpenVibe.Media', icon: 'fa-photo-film', url: 'https://openvibe.media', color: '#22d3ee' },
community: { name: 'OpenVibe.Community', icon: 'fa-people-group', url: 'https://openvibe.community', color: '#f59e0b' },
community: { name: 'OpenVibe.Community', icon: 'fa-people-group', url: 'https://openvibe.community', color: '#f59e0b' },
+ space: { name: 'OpenVibe.Space', icon: 'fa-comments', url: 'https://openvibe.space', color: '#818cf8' },
discord: { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj', color: '#5865f2' },
discord: { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj', color: '#5865f2' },
network: { name: 'OpenVibe.Network', icon: 'fa-circle-nodes', url: '/', color: '#3b82f6' },
network: { name: 'OpenVibe.Network', icon: 'fa-circle-nodes', url: '/', color: '#3b82f6' },
};
};
@@ -66,7 +66,7 @@ li.now .dot i{animation:spin 1s linear infinite}
@@ -66,7 +66,7 @@ li.now .dot i{animation:spin 1s linear infinite}
// safe-redirect:begin
// safe-redirect:begin
// Where a sign-in may send the browser: a path on this site, this origin, or https on a zone
// Where a sign-in may send the browser: a path on this site, this origin, or https on a zone
// OpenVibe owns (server/auth/sso-owned.js). Never javascript:/data:, never another host.
// OpenVibe owns (server/auth/sso-owned.js). Never javascript:/data:, never another host.
- const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];
+ const OV_OWNED_ZONES = ['openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.space', 'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews', 'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons', 'openre.stream'];
const OV_USER_CONTENT_ZONES = ['openvibe.host']; // tenant sites: only the apex is ours
const OV_USER_CONTENT_ZONES = ['openvibe.host']; // tenant sites: only the apex is ours
function safeNext(raw) {
function safeNext(raw) {
const s = String(raw == null ? '' : raw);
const s = String(raw == null ? '' : raw);
@@ -7,6 +7,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -7,6 +7,7 @@ const cache = require('openvibe-shared/cache-policy');
* chat.moderation.action chat moderation (Chat, and Live's chat bridge)
* chat.moderation.action chat moderation (Chat, and Live's chat bridge)
* live.moderation.action Live's staff actions outside chat: bans, force-ended streams, admin deletions
* live.moderation.action Live's staff actions outside chat: bans, force-ended streams, admin deletions
* community.moderation.action staff actions on other people's content in Community
* community.moderation.action staff actions on other people's content in Community
+ * space.moderation.action staff actions on forum threads and posts in Space
* tips.interaction.moderated a paid message filtered, held, hidden or restored
* tips.interaction.moderated a paid message filtered, held, hidden or restored
* billing.staff.action a staff money action in the Billing console
* billing.staff.action a staff money action in the Billing console
* <service>.moderation.action common.moderation-action@1 (Contracts 0.53.0) from tools, games, wiki,
* <service>.moderation.action common.moderation-action@1 (Contracts 0.53.0) from tools, games, wiki,
@@ -26,7 +27,7 @@ const { staffClaims } = require('../auth/staff-claims');
@@ -26,7 +27,7 @@ const { staffClaims } = require('../auth/staff-claims');
// Producers whose event is common.moderation-action@1 (action, target { type, id, owner_subject }, actor_subject, reason, details).
// Producers whose event is common.moderation-action@1 (action, target { type, id, owner_subject }, actor_subject, reason, details).
const COMMON_SERVICES = Object.freeze(['media', 'tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes']);
const COMMON_SERVICES = Object.freeze(['media', 'tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes']);
-const TOPICS = Object.freeze(['chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',
+const TOPICS = Object.freeze(['chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'space.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',
...COMMON_SERVICES.map((svc) => `${svc}.moderation.action`)]);
...COMMON_SERVICES.map((svc) => `${svc}.moderation.action`)]);
const str = (v, n) => (v == null || v === '' ? null : String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, n));
const str = (v, n) => (v == null || v === '' ? null : String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, n));
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
@@ -48,9 +49,10 @@ function rowOf(event) {
@@ -48,9 +49,10 @@ function rowOf(event) {
target_type: p.target_subject || p.target_user_id ? 'user' : null, target_id: str(p.target_subject || p.target_user_id, 128), target_subject: str(p.target_subject, 64),
target_type: p.target_subject || p.target_user_id ? 'user' : null, target_id: str(p.target_subject || p.target_user_id, 128), target_subject: str(p.target_subject, 64),
scope: str(`${p.scope_type || 'site'}${p.scope_id != null ? `:${p.scope_id}` : ''}`, 128), reason: str(d.reason, 500), details: json(d) };
scope: str(`${p.scope_type || 'site'}${p.scope_id != null ? `:${p.scope_id}` : ''}`, 128), reason: str(d.reason, 500), details: json(d) };
}
}
- case 'community.moderation.action': {
+ case 'community.moderation.action':
+ case 'space.moderation.action': {
const t = obj(p.target);
const t = obj(p.target);
- return { ...base, service: 'community', action: str(p.action, 64) || 'unknown', actor_subject: str(p.actor_subject || actorId, 64),
+ return { ...base, service: event.event_type.split('.')[0], action: str(p.action, 64) || 'unknown', actor_subject: str(p.actor_subject || actorId, 64),
target_type: str(t.type, 32), target_id: str(t.id, 200), target_subject: str(t.owner_subject, 64), scope: null, reason: str(p.reason, 500), details: json(p.details) };
target_type: str(t.type, 32), target_id: str(t.id, 200), target_subject: str(t.owner_subject, 64), scope: null, reason: str(p.reason, 500), details: json(p.details) };
}
}
case 'tips.interaction.moderated': {
case 'tips.interaction.moderated': {
@@ -40,6 +40,7 @@ const AREAS = [
@@ -40,6 +40,7 @@ const AREAS = [
{ item: 'Moderation audit across services', where: [{ kind: 'tab', tab: 'modlog' }], service: 'network' },
{ item: 'Moderation audit across services', where: [{ kind: 'tab', tab: 'modlog' }], service: 'network' },
{ item: 'Chat logs and chat moderation', where: [{ kind: 'tab', tab: 'chat-logs' }], service: 'chat' },
{ item: 'Chat logs and chat moderation', where: [{ kind: 'tab', tab: 'chat-logs' }], service: 'chat' },
{ item: 'Community moderation', where: [{ kind: 'api', api: 'OpenVibe.Community staff routes', note: 'no console yet' }], service: 'community' },
{ item: 'Community moderation', where: [{ kind: 'api', api: 'OpenVibe.Community staff routes', note: 'no console yet' }], service: 'community' },
+ { item: 'Space forum moderation', where: [{ kind: 'api', api: 'OpenVibe.Space forum moderator routes', note: 'no console yet' }], service: 'space' },
{ item: 'Payments, cashouts and the money freeze', where: [{ kind: 'url', url: 'https://billing.openvibe.network/', note: 'Billing console' }, { kind: 'tab', tab: 'cashouts' }, { kind: 'tab', tab: 'payments' }], service: 'billing' },
{ item: 'Payments, cashouts and the money freeze', where: [{ kind: 'url', url: 'https://billing.openvibe.network/', note: 'Billing console' }, { kind: 'tab', tab: 'cashouts' }, { kind: 'tab', tab: 'payments' }], service: 'billing' },
{ item: 'Loyalty (OpenCoins, channel points)', where: [{ kind: 'api', api: 'live.loyalty user module; Network /internal/coins/*', note: 'summaries only; loyalty is never money' }], service: 'network' },
{ item: 'Loyalty (OpenCoins, channel points)', where: [{ kind: 'api', api: 'live.loyalty user module; Network /internal/coins/*', note: 'summaries only; loyalty is never money' }], service: 'network' },
{ item: 'Developer apps and trust (Codes)', where: [{ kind: 'url', url: 'https://openvibe.codes/staff' }], service: 'codes' },
{ item: 'Developer apps and trust (Codes)', where: [{ kind: 'url', url: 'https://openvibe.codes/staff' }], service: 'codes' },
@@ -5,7 +5,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -5,7 +5,7 @@ const cache = require('openvibe-shared/cache-policy');
*
*
* GET /api/v1/staff/capabilities the signed-in person's role, owner flag and staff capabilities
* GET /api/v1/staff/capabilities the signed-in person's role, owner flag and staff capabilities
* GET /api/v1/staff/moderators the network's staff (global moderators, admins, the owner) with
* GET /api/v1/staff/moderators the network's staff (global moderators, admins, the owner) with
- * their capabilities; ?service=chat|live|community|pastes|calls keeps
+ * their capabilities; ?service=chat|live|community|space|pastes|calls keeps
* those who moderate it. For staff holding staff.moderation.logs, or a
* those who moderate it. For staff holding staff.moderation.logs, or a
* service token with network.staff.read (Contracts 0.47.0).
* service token with network.staff.read (Contracts 0.47.0).
*
*
@@ -17,7 +17,7 @@ const { staffClaims } = require('../auth/staff-claims');
@@ -17,7 +17,7 @@ const { staffClaims } = require('../auth/staff-claims');
const { isOwner } = require('../auth/owner-guard');
const { isOwner } = require('../auth/owner-guard');
// Which staff capability moderates a service; anything else is general content moderation.
// Which staff capability moderates a service; anything else is general content moderation.
-const MODERATES = { chat: 'staff.moderation.chat', live: 'staff.moderation.channels', community: 'staff.moderation.discussions', pastes: 'staff.moderation.pastes', calls: 'staff.moderation.calls' };
+const MODERATES = { chat: 'staff.moderation.chat', live: 'staff.moderation.channels', community: 'staff.moderation.discussions', space: 'staff.moderation.discussions', pastes: 'staff.moderation.pastes', calls: 'staff.moderation.calls' };
async function staffList(db, { service = null } = {}) {
async function staffList(db, { service = null } = {}) {
const need = service ? (MODERATES[service] || 'staff.content.moderate') : null;
const need = service ? (MODERATES[service] || 'staff.content.moderate') : null;
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
// The zones OpenVibe owns. Cross-site trust (sso/check framing, FedCM relying parties, sign-in
// The zones OpenVibe owns. Cross-site trust (sso/check framing, FedCM relying parties, sign-in
// handoff targets) is limited to these — never to openvibe.<anything>, which anyone can register.
// handoff targets) is limited to these — never to openvibe.<anything>, which anyone can register.
const OWNED_ZONES = [
const OWNED_ZONES = [
- 'openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community',
+ 'openvibe.network', 'openvibe.live', 'openvibe.tools', 'openvibe.media', 'openvibe.games', 'openvibe.community', 'openvibe.space',
'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews',
'openvibe.chat', 'openvibe.codes', 'openvibe.blog', 'openvibe.wiki', 'openvibe.news', 'openvibe.reviews',
'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons',
'openvibe.tips', 'openvibe.vip', 'openvibe.trade', 'openvibe.host', 'openvibe.deals', 'openvibe.coupons',
'openre.stream',
'openre.stream',
@@ -21,7 +21,7 @@ function isTrustedHost(hostname) {
@@ -21,7 +21,7 @@ function isTrustedHost(hostname) {
/** Which RP origins an OAuth client may exchange FedCM assertions for. */
/** Which RP origins an OAuth client may exchange FedCM assertions for. */
function clientOriginMatcher(client) {
function clientOriginMatcher(client) {
const id = String(client?.client_id || '');
const id = String(client?.client_id || '');
- const zoneOf = { live: 'openvibe.live', tools: 'openvibe.tools', games: 'openvibe.games', media: 'openvibe.media', community: 'openvibe.community', network: 'openvibe.network' }[id];
+ const zoneOf = { live: 'openvibe.live', tools: 'openvibe.tools', games: 'openvibe.games', media: 'openvibe.media', community: 'openvibe.community', space: 'openvibe.space', network: 'openvibe.network' }[id];
let uris = [];
let uris = [];
try { uris = JSON.parse(client?.redirect_uris || '[]'); } catch { /* */ }
try { uris = JSON.parse(client?.redirect_uris || '[]'); } catch { /* */ }
const registered = new Set(uris.map(u => { try { return new URL(u).origin; } catch { return null; } }).filter(Boolean));
const registered = new Set(uris.map(u => { try { return new URL(u).origin; } catch { return null; } }).filter(Boolean));
@@ -29,6 +29,9 @@ const DEFAULT_TARGETS = [
@@ -29,6 +29,9 @@ const DEFAULT_TARGETS = [
{ id: 'community', name: 'OpenVibe.Community', origin: 'https://openvibe.community',
{ id: 'community', name: 'OpenVibe.Community', origin: 'https://openvibe.community',
login: 'https://openvibe.community/auth/login?silent=1&next={next}',
login: 'https://openvibe.community/auth/login?silent=1&next={next}',
logout: 'https://openvibe.community/auth/logout?next={next}' },
logout: 'https://openvibe.community/auth/logout?next={next}' },
+ { id: 'space', name: 'OpenVibe.Space', origin: 'https://openvibe.space',
+ login: 'https://openvibe.space/auth/login?silent=1&next={next}',
+ logout: 'https://openvibe.space/auth/logout?next={next}' },
];
];
/**
/**
@@ -88,6 +88,7 @@ async function seedDb(db, { log = console } = {}) {
@@ -88,6 +88,7 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'games', name: 'OpenVibe.Games', redirect_uris: ['https://openvibe.games/auth/callback', 'https://play.openvibe.games/auth/callback'] },
{ client_id: 'games', name: 'OpenVibe.Games', redirect_uris: ['https://openvibe.games/auth/callback', 'https://play.openvibe.games/auth/callback'] },
{ client_id: 'media', name: 'OpenVibe.Media', redirect_uris: ['https://openvibe.media/auth/callback'] },
{ client_id: 'media', name: 'OpenVibe.Media', redirect_uris: ['https://openvibe.media/auth/callback'] },
{ client_id: 'community', name: 'OpenVibe.Community', redirect_uris: ['https://openvibe.community/auth/callback'] },
{ client_id: 'community', name: 'OpenVibe.Community', redirect_uris: ['https://openvibe.community/auth/callback'] },
+ { client_id: 'space', name: 'OpenVibe.Space', redirect_uris: ['https://openvibe.space/auth/callback'] },
// Waves 9-16 products with a signed-in UI. Create each with server/setup/service-principal.js
// Waves 9-16 products with a signed-in UI. Create each with server/setup/service-principal.js
// first (secret into its env file); this only adds the redirect URI to that client.
// first (secret into its env file); this only adds the redirect URI to that client.
{ client_id: 'tips', name: 'OpenVibe.Tips', redirect_uris: ['https://openvibe.tips/auth/callback'] },
{ client_id: 'tips', name: 'OpenVibe.Tips', redirect_uris: ['https://openvibe.tips/auth/callback'] },
@@ -128,6 +129,8 @@ async function seedDb(db, { log = console } = {}) {
@@ -128,6 +129,8 @@ async function seedDb(db, { log = console } = {}) {
{ clientId: 'tools', extraUris: ['http://localhost:4001/auth/callback'] },
{ clientId: 'tools', extraUris: ['http://localhost:4001/auth/callback'] },
{ clientId: 'games', extraUris: ['http://localhost:8000/auth/callback', 'http://localhost:5173/auth/callback'] },
{ clientId: 'games', extraUris: ['http://localhost:8000/auth/callback', 'http://localhost:5173/auth/callback'] },
{ clientId: 'media', extraUris: ['http://localhost:4100/auth/callback'] },
{ clientId: 'media', extraUris: ['http://localhost:4100/auth/callback'] },
+ { clientId: 'community', extraUris: ['http://localhost:4200/auth/callback'] },
+ { clientId: 'space', extraUris: ['http://localhost:4940/auth/callback'] },
];
];
for (const { clientId, extraUris } of localClients) {
for (const { clientId, extraUris } of localClients) {
try { await mergeRedirectUris(db, clientId, extraUris, { log, local: true }); }
try { await mergeRedirectUris(db, clientId, extraUris, { log, local: true }); }
@@ -22,7 +22,7 @@ const toolsCatalog = require('../domains/catalog');
@@ -22,7 +22,7 @@ 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;
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: 6, tools: 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
/**
/**
* Footer copy comes from OpenVibe.AI's network.site_copy workflow (roadmap Wave 13, ADR-015), called with a
* Footer copy comes from OpenVibe.AI's network.site_copy workflow (roadmap Wave 13, ADR-015), called with a
@@ -16,7 +16,7 @@ const MAX_PER_USER = 2000; // oldest rows beyond this are trimmed on wr
@@ -16,7 +16,7 @@ const MAX_PER_USER = 2000; // oldest rows beyond this are trimmed on wr
const DEDUPE_WINDOW_MIN = 10; // same URL within this window bumps the entry instead of adding one
const DEDUPE_WINDOW_MIN = 10; // same URL within this window bumps the entry instead of adding one
const TYPES = new Set(['tool', 'stream', 'vod', 'clip', 'paste', 'game', 'page', 'post', 'account', 'theme', 'download', 'chat']);
const TYPES = new Set(['tool', 'stream', 'vod', 'clip', 'paste', 'game', 'page', 'post', 'account', 'theme', 'download', 'chat']);
const SERVICE_LABELS = {
const SERVICE_LABELS = {
- live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', community: 'Community',
+ live: 'Live', tools: 'Tools', games: 'Games', media: 'Media', network: 'Network', community: 'Community', space: 'Space',
chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips',
chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips',
vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons', openre: 'OpenRe.Stream',
vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons', openre: 'OpenRe.Stream',
};
};
@@ -44,6 +44,9 @@ const DEFAULT_GRANTS = [
@@ -44,6 +44,9 @@ const DEFAULT_GRANTS = [
// pastes into Community on behalf of its users and its AI jobs.
// pastes into Community on behalf of its users and its AI jobs.
['community', 'identity.subject.resolve', SELF_AUDIENCE, []],
['community', 'identity.subject.resolve', SELF_AUDIENCE, []],
['community', 'media.object.upload', 'openvibe.media', ['community']],
['community', 'media.object.upload', 'openvibe.media', ['community']],
+ // Plan T10: Space owns the forums and stores their attachments in its own Media namespace.
+ ['space', 'identity.subject.resolve', SELF_AUDIENCE, []],
+ ['space', 'media.object.upload', 'openvibe.media', ['space']],
// Wave 11: Tools job results as Media objects; Wave 12: Games map-editor assets.
// Wave 11: Tools job results as Media objects; Wave 12: Games map-editor assets.
// tools.* : developer projects' results go to tools.app.<project_id>[.sandbox] (WS-L task 5).
// tools.* : developer projects' results go to tools.app.<project_id>[.sandbox] (WS-L task 5).
['tools', 'media.object.upload', 'openvibe.media', ['tools', 'tools.*']],
['tools', 'media.object.upload', 'openvibe.media', ['tools', 'tools.*']],
@@ -76,7 +79,7 @@ const DEFAULT_GRANTS = [
@@ -76,7 +79,7 @@ const DEFAULT_GRANTS = [
['games', 'mods.grant.manage', SELF_AUDIENCE, []],
['games', 'mods.grant.manage', SELF_AUDIENCE, []],
// Account export and deletion (ADR-033): the services that keep data about people push their export part and
// Account export and deletion (ADR-033): the services that keep data about people push their export part and
// confirm a deletion; the holders of each grant are the services Network waits for.
// confirm a deletion; the holders of each grant are the services Network waits for.
- ...['live', 'chat', 'community', 'media', 'games'].flatMap((svc) => [
+ ...['live', 'chat', 'community', 'space', 'media', 'games'].flatMap((svc) => [
[svc, 'network.account.export.contribute', SELF_AUDIENCE, []],
[svc, 'network.account.export.contribute', SELF_AUDIENCE, []],
[svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],
[svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],
]),
]),
@@ -212,7 +215,7 @@ const DEFAULT_GRANTS = [
@@ -212,7 +215,7 @@ const DEFAULT_GRANTS = [
// A streamer's daily AI-viewer budget is an AI quota on their attribution (live:user:<id>), which Live sets.
// A streamer's daily AI-viewer budget is an AI quota on their attribution (live:user:<id>), which Live sets.
['live', 'ai.quota.attribution.manage', 'openvibe.ai', []],
['live', 'ai.quota.attribution.manage', 'openvibe.ai', []],
// Wave 3: producers publish to OpenVibe.Events (their own source only, enforced by Events).
// Wave 3: producers publish to OpenVibe.Events (their own source only, enforced by Events).
- ...['live', 'media', 'network', 'community', 'billing', 'chat', 'tools', 'games', 'search', 'sources', 'tips'].map(c => [c, 'events.event.publish', 'openvibe.events', []]),
+ ...['live', 'media', 'network', 'community', 'space', 'billing', 'chat', 'tools', 'games', 'search', 'sources', 'tips'].map(c => [c, 'events.event.publish', 'openvibe.events', []]),
// Wave 14: Search subscribes to <owner>.index_document.* deliveries.
// Wave 14: Search subscribes to <owner>.index_document.* deliveries.
['search', 'events.subscription.manage', 'openvibe.events', []],
['search', 'events.subscription.manage', 'openvibe.events', []],
// Plan T9: Search's saved-search notifier pushes one notification per new match through Network
// Plan T9: Search's saved-search notifier pushes one notification per new match through Network
@@ -225,6 +228,7 @@ const DEFAULT_GRANTS = [
@@ -225,6 +228,7 @@ const DEFAULT_GRANTS = [
// VIP gates in products: Chat's subscriber badge, Community's and Blog's members-only content, Live's own checks.
// VIP gates in products: Chat's subscriber badge, Community's and Blog's members-only content, Live's own checks.
['chat', 'vip.entitlement.check', 'openvibe.vip', []],
['chat', 'vip.entitlement.check', 'openvibe.vip', []],
['community', 'vip.resource.policy.evaluate', 'openvibe.vip', []],
['community', 'vip.resource.policy.evaluate', 'openvibe.vip', []],
+ ['space', 'vip.resource.policy.evaluate', 'openvibe.vip', []],
['blog', 'vip.resource.policy.evaluate', 'openvibe.vip', []],
['blog', 'vip.resource.policy.evaluate', 'openvibe.vip', []],
// WS-K task 8: Wiki's VIP spaces and pages (Wiki server/integrations/vip.js).
// WS-K task 8: Wiki's VIP spaces and pages (Wiki server/integrations/vip.js).
['wiki', 'vip.resource.policy.evaluate', 'openvibe.vip', []],
['wiki', 'vip.resource.policy.evaluate', 'openvibe.vip', []],
@@ -232,6 +236,8 @@ const DEFAULT_GRANTS = [
@@ -232,6 +236,8 @@ const DEFAULT_GRANTS = [
['community', 'events.subscription.manage', 'openvibe.events', []],
['community', 'events.subscription.manage', 'openvibe.events', []],
['community', 'events.event.read', 'openvibe.events', []],
['community', 'events.event.read', 'openvibe.events', []],
['community', 'events.event.publish', 'openvibe.events', []], // community.* events (Community server/events.js, 2026-09-24)
['community', 'events.event.publish', 'openvibe.events', []], // community.* events (Community server/events.js, 2026-09-24)
+ ['space', 'events.subscription.manage', 'openvibe.events', []],
+ ['space', 'events.event.read', 'openvibe.events', []],
['billing', 'identity.subject.resolve', SELF_AUDIENCE, []],
['billing', 'identity.subject.resolve', SELF_AUDIENCE, []],
['chat', 'identity.subject.resolve', SELF_AUDIENCE, []],
['chat', 'identity.subject.resolve', SELF_AUDIENCE, []],
['live', 'identity.subject.resolve', SELF_AUDIENCE, []],
['live', 'identity.subject.resolve', SELF_AUDIENCE, []],
@@ -267,6 +273,7 @@ const DEFAULT_GRANTS = [
@@ -267,6 +273,7 @@ const DEFAULT_GRANTS = [
// both also follow network.block.changed through their own Events subscriptions.
// both also follow network.block.changed through their own Events subscriptions.
['chat', 'network.blocks.read', SELF_AUDIENCE, []],
['chat', 'network.blocks.read', SELF_AUDIENCE, []],
['community', 'network.blocks.read', SELF_AUDIENCE, []],
['community', 'network.blocks.read', SELF_AUDIENCE, []],
+ ['space', 'network.blocks.read', SELF_AUDIENCE, []],
['live', 'chat.live_bridge.write', 'openvibe.chat', []],
['live', 'chat.live_bridge.write', 'openvibe.chat', []],
['live', 'chat.presence.read', 'openvibe.chat', []],
['live', 'chat.presence.read', 'openvibe.chat', []],
// Plan T3: Live reads a channel's moderation settings, its moderators and emote count from Chat.
// Plan T3: Live reads a channel's moderation settings, its moderators and emote count from Chat.
@@ -148,6 +148,7 @@ app.use(helmet({
@@ -148,6 +148,7 @@ app.use(helmet({
"https://openvibe.games", "https://play.openvibe.games",
"https://openvibe.games", "https://play.openvibe.games",
"https://openvibe.media",
"https://openvibe.media",
"https://openvibe.community",
"https://openvibe.community",
+ "https://openvibe.space",
"https://openvibe.blog",
"https://openvibe.blog",
"https://cloudflareinsights.com", // Cloudflare Web Analytics reports here (see script-src)
"https://cloudflareinsights.com", // Cloudflare Web Analytics reports here (see script-src)
// Release notifications: release-watch's EventSource on the Events realtime stream (openvibe-shared 1.17)
// Release notifications: release-watch's EventSource on the Events realtime stream (openvibe-shared 1.17)
@@ -241,6 +242,7 @@ function buildAllowedOriginsSet() {
@@ -241,6 +242,7 @@ function buildAllowedOriginsSet() {
'https://openvibe.games', 'https://www.openvibe.games', 'https://play.openvibe.games',
'https://openvibe.games', 'https://www.openvibe.games', 'https://play.openvibe.games',
'https://openvibe.media',
'https://openvibe.media',
'https://openvibe.community',
'https://openvibe.community',
+ 'https://openvibe.space',
]) {
]) {
origins.add(o);
origins.add(o);
}
}
@@ -251,6 +253,8 @@ function buildAllowedOriginsSet() {
@@ -251,6 +253,8 @@ function buildAllowedOriginsSet() {
'http://localhost:4000', 'http://127.0.0.1:4000', // network
'http://localhost:4000', 'http://127.0.0.1:4000', // network
'http://localhost:4001', // tools gateway
'http://localhost:4001', // tools gateway
'http://localhost:4100', // media
'http://localhost:4100', // media
+ 'http://localhost:4200', // community
+ 'http://localhost:4940', // space
'http://localhost:8000', // games
'http://localhost:8000', // games
'http://localhost:5173', // games vite dev
'http://localhost:5173', // games vite dev
]) {
]) {
@@ -69,7 +69,7 @@ function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = gl
@@ -69,7 +69,7 @@ function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = gl
return { router: r, feed, queryOf };
return { router: r, feed, queryOf };
}
}
-const SITES = ['live', 'network', 'tools', 'media', 'community', 'chat', 'games', 'blog', 'wiki', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes', 'host', 'ai', 'search', 'sources', 'events', 'billing', 'tips', 'vip', 'openre', 'bot', 'sites'];
+const SITES = ['live', 'network', 'tools', 'media', 'community', 'space', 'chat', 'games', 'blog', 'wiki', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes', 'host', 'ai', 'search', 'sources', 'events', 'billing', 'tips', 'vip', 'openre', 'bot', 'sites'];
const NAMES = { ai: 'AI', vip: 'VIP', openre: 'OpenRe' };
const NAMES = { ai: 'AI', vip: 'VIP', openre: 'OpenRe' };
const nameOf = (id) => NAMES[id] || (id ? id.charAt(0).toUpperCase() + id.slice(1) : '');
const nameOf = (id) => NAMES[id] || (id ? id.charAt(0).toUpperCase() + id.slice(1) : '');
@@ -32,8 +32,8 @@ const exportDir = path.join(dir, 'account-exports');
@@ -32,8 +32,8 @@ const exportDir = path.join(dir, 'account-exports');
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
const db = getDb();
const db = getDb();
console.log = log;
console.log = log;
-// The five services that keep data about people are registered clients (production has them all).
-for (const c of ['live', 'chat', 'community', 'media', 'games', 'tools']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);
+// The services that keep data about people are registered clients (production has them all).
+for (const c of ['live', 'chat', 'community', 'space', 'media', 'games', 'tools']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
await principals.ensureSchema(db);
await principals.ensureSchema(db);
require('../server/identity/follows').ensureSchema(db);
require('../server/identity/follows').ensureSchema(db);
@@ -93,7 +93,7 @@ const server = http.createServer(app);
@@ -93,7 +93,7 @@ const server = http.createServer(app);
// ── Export ──
// ── Export ──
const expected = await accountData.expectedServices(db, 'network.account.export.contribute');
const expected = await accountData.expectedServices(db, 'network.account.export.contribute');
- assert.deepStrictEqual(expected, ['chat', 'community', 'games', 'live', 'media'], 'the holders of the contribute grant');
+ assert.deepStrictEqual(expected, ['chat', 'community', 'games', 'live', 'media', 'space'], 'the holders of the contribute grant');
let r = await call('POST', '/api/v1/account/export', tok(dana));
let r = await call('POST', '/api/v1/account/export', tok(dana));
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
assert.strictEqual(r.status, 201, JSON.stringify(r.body));
assert.ok(validate('network.account-export@1', r.body).valid, JSON.stringify(validate('network.account-export@1', r.body).errors));
assert.ok(validate('network.account-export@1', r.body).valid, JSON.stringify(validate('network.account-export@1', r.body).errors));
@@ -133,7 +133,7 @@ const server = http.createServer(app);
@@ -133,7 +133,7 @@ const server = http.createServer(app);
r = await call('GET', `/api/v1/account/export/${exp}`, tok(dana));
r = await call('GET', `/api/v1/account/export/${exp}`, tok(dana));
assert.strictEqual(r.body.status, 'partial');
assert.strictEqual(r.body.status, 'partial');
assert.ok(validate('network.account-export@1', r.body).valid);
assert.ok(validate('network.account-export@1', r.body).valid);
- assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), ['network:received', 'chat:received', 'live:received', 'community:missing', 'games:missing', 'media:missing']);
+ assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), ['network:received', 'chat:received', 'live:received', 'community:missing', 'games:missing', 'media:missing', 'space:missing']);
assert.deepStrictEqual(notified.map((n) => n[0]), [dana.id], 'the person is told');
assert.deepStrictEqual(notified.map((n) => n[0]), [dana.id], 'the person is told');
r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));
r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
@@ -141,6 +141,7 @@ const server = http.createServer(app);
@@ -141,6 +141,7 @@ const server = http.createServer(app);
assert.match(r.headers.get('content-disposition'), /attachment; filename="openvibe-dana-\d{4}-\d\d-\d\d\.zip"/);
assert.match(r.headers.get('content-disposition'), /attachment; filename="openvibe-dana-\d{4}-\d\d-\d\d\.zip"/);
const files = zip.read(r.buf);
const files = zip.read(r.buf);
assert.ok(files['README.txt'].toString().includes('community: MISSING'));
assert.ok(files['README.txt'].toString().includes('community: MISSING'));
+ assert.ok(files['README.txt'].toString().includes('space: MISSING'));
assert.deepStrictEqual(JSON.parse(files['live/follows.json']).map((f) => f.channel), ['xavier', 'yolanda'], 'the replacing part');
assert.deepStrictEqual(JSON.parse(files['live/follows.json']).map((f) => f.channel), ['xavier', 'yolanda'], 'the replacing part');
const account = JSON.parse(files['network/account.json']);
const account = JSON.parse(files['network/account.json']);
assert.deepStrictEqual([account.username, account.email, account.subject_id], ['dana', '[email protected]', dana.subject_id]);
assert.deepStrictEqual([account.username, account.email, account.subject_id], ['dana', '[email protected]', dana.subject_id]);
@@ -158,7 +159,7 @@ const server = http.createServer(app);
@@ -158,7 +159,7 @@ const server = http.createServer(app);
assert.ok(Number(r.headers.get('retry-after')) > 0);
assert.ok(Number(r.headers.get('retry-after')) > 0);
// Every expected service answers: ready at once.
// Every expected service answers: ready at once.
- for (const s of ['community', 'games', 'media']) await db.prepare("UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = 'network.account.export.contribute'").run(s);
+ for (const s of ['community', 'games', 'media', 'space']) await db.prepare("UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = 'network.account.export.contribute'").run(s);
r = await call('POST', '/api/v1/account/export', tok(eve));
r = await call('POST', '/api/v1/account/export', tok(eve));
const exp2 = r.body.export_id;
const exp2 = r.body.export_id;
await call('POST', `/internal/account-exports/${exp2}/parts`, svc('live'), part([{ name: 'profile.json', content: {} }], eve.subject_id));
await call('POST', `/internal/account-exports/${exp2}/parts`, svc('live'), part([{ name: 'profile.json', content: {} }], eve.subject_id));
@@ -263,7 +264,7 @@ const server = http.createServer(app);
@@ -263,7 +264,7 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 403);
assert.strictEqual(r.status, 403);
r = await call('GET', '/api/admin/account-deletions', tok(boss));
r = await call('GET', '/api/admin/account-deletions', tok(boss));
const row = r.body.deletions.find((d) => d.deletion_id === del);
const row = r.body.deletions.find((d) => d.deletion_id === del);
- assert.deepStrictEqual([row.confirmed, row.outstanding], [['chat'], ['community', 'games', 'live', 'media']]);
+ assert.deepStrictEqual([row.confirmed, row.outstanding], [['chat'], ['community', 'games', 'live', 'media', 'space']]);
// Staff cancel a scheduled deletion only with a reason.
// Staff cancel a scheduled deletion only with a reason.
await call('POST', '/api/v1/account/deletion', tok(eve), { confirm_username: 'eve' });
await call('POST', '/api/v1/account/deletion', tok(eve), { confirm_username: 'eve' });
@@ -57,7 +57,7 @@ const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHE
@@ -57,7 +57,7 @@ const rows = async (userId) => await db.prepare('SELECT * FROM notifications WHE
(async () => {
(async () => {
// The subscriptions this consumer is meant for, and nothing else.
// The subscriptions this consumer is meant for, and nothing else.
- assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',
+ assert.deepStrictEqual(TOPICS, ['deals.watch.matched', 'trade.alert.triggered', 'live.stream.started', 'live.stream.ended', 'chat.moderation.action', 'live.moderation.action', 'community.moderation.action', 'space.moderation.action', 'tips.interaction.moderated', 'billing.staff.action',
...['media', 'tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes'].map((svc) => `${svc}.moderation.action`),
...['media', 'tools', 'games', 'wiki', 'blog', 'news', 'reviews', 'deals', 'coupons', 'trade', 'codes'].map((svc) => `${svc}.moderation.action`),
'tools.usage.recorded', 'events.usage.recorded']);
'tools.usage.recorded', 'events.usage.recorded']);
assert.deepStrictEqual(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');
assert.deepStrictEqual(topicsFrom([]), TOPICS, 'scripts/subscribe-events.js subscribes every topic');
@@ -25,6 +25,8 @@ const tools = clientOriginMatcher({ client_id: 'tools', redirect_uris: '["https:
@@ -25,6 +25,8 @@ const tools = clientOriginMatcher({ client_id: 'tools', redirect_uris: '["https:
assert.ok(tools('https://yt.openvibe.tools') && tools('https://openvibe.tools') && !tools('https://openvibe.live'), 'tools may exchange for any *.openvibe.tools origin only');
assert.ok(tools('https://yt.openvibe.tools') && tools('https://openvibe.tools') && !tools('https://openvibe.live'), 'tools may exchange for any *.openvibe.tools origin only');
const live = clientOriginMatcher({ client_id: 'live', redirect_uris: '["https://openvibe.live/api/auth/callback"]' });
const live = clientOriginMatcher({ client_id: 'live', redirect_uris: '["https://openvibe.live/api/auth/callback"]' });
assert.ok(live('https://openvibe.live') && !live('https://openvibe.tools'));
assert.ok(live('https://openvibe.live') && !live('https://openvibe.tools'));
+const space = clientOriginMatcher({ client_id: 'space', redirect_uris: '["https://openvibe.space/auth/callback"]' });
+assert.ok(space('https://openvibe.space') && !space('https://openvibe.community'));
const a = fedcm.accountOf(users[7], 'https://openvibe.network');
const a = fedcm.accountOf(users[7], 'https://openvibe.network');
assert.deepStrictEqual(Object.keys(a).sort(), ['email', 'given_name', 'id', 'name', 'picture', 'username']);
assert.deepStrictEqual(Object.keys(a).sort(), ['email', 'given_name', 'id', 'name', 'picture', 'username']);
assert.strictEqual(a.picture, 'https://openvibe.network/data/avatars/7.png');
assert.strictEqual(a.picture, 'https://openvibe.network/data/avatars/7.png');
@@ -14,7 +14,9 @@ assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
@@ -14,7 +14,9 @@ assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
assert.equal(siteForHost('evil.example'), null);
assert.equal(siteForHost('evil.example'), null);
assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');
assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');
let p = svc.payloadFor('openvibe.media');
let p = svc.payloadFor('openvibe.media');
-assert.deepEqual(p.nav.map(n => n.id), ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'codes', 'blog', 'wiki', 'host', 'bot'], 'cold start follows the base order');
+assert.deepEqual(p.nav.map(n => n.id), ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'codes', 'blog', 'wiki', 'host', 'bot'], 'cold start follows the base order');
+assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'the forum goes to Space');
+assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community remains for pastes and Pulse');
assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');
assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');
assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a site never recommends itself');
assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a site never recommends itself');
assert.ok(p.soon.length >= 9, 'every planned site still listed as soon (chat launched 2026-09-24)');
assert.ok(p.soon.length >= 9, 'every planned site still listed as soon (chat launched 2026-09-24)');
@@ -20,6 +20,7 @@ const db = getDb();
@@ -20,6 +20,7 @@ const db = getDb();
console.log = log;
console.log = log;
const principals = require('../server/identity/principals');
const principals = require('../server/identity/principals');
const grants = require('../server/identity/grants-admin');
const grants = require('../server/identity/grants-admin');
+assert.strictEqual(require('../server/admin/staff-api').MODERATES.space, 'staff.moderation.discussions');
for (const c of ['live', 'media']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);
for (const c of ['live', 'media']) await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1) ON CONFLICT DO NOTHING").run(c, c);
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('thirdparty', 'x', 'x', '[]', 0) ON CONFLICT DO NOTHING").run();
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES ('thirdparty', 'x', 'x', '[]', 0) ON CONFLICT DO NOTHING").run();
await principals.ensureSchema(db);
await principals.ensureSchema(db);
@@ -15,12 +15,15 @@ assert.strictEqual(allowedUrl('https://evil.example.com/'), null, 'off-network U
@@ -15,12 +15,15 @@ assert.strictEqual(allowedUrl('https://evil.example.com/'), null, 'off-network U
assert.strictEqual(allowedUrl('javascript:alert(1)'), null);
assert.strictEqual(allowedUrl('javascript:alert(1)'), null);
assert.deepStrictEqual(serviceFromUrl('https://pastes.openvibe.tools/p/x'), { service: 'tools', sub: 'pastes' });
assert.deepStrictEqual(serviceFromUrl('https://pastes.openvibe.tools/p/x'), { service: 'tools', sub: 'pastes' });
assert.deepStrictEqual(serviceFromUrl('https://openvibe.live/@someone'), { service: 'live', sub: null });
assert.deepStrictEqual(serviceFromUrl('https://openvibe.live/@someone'), { service: 'live', sub: null });
+assert.deepStrictEqual(serviceFromUrl('https://openvibe.space/s/general'), { service: 'space', sub: null });
assert.deepStrictEqual(serviceFromUrl('https://ingest.openre.stream/'), { service: 'openre', sub: 'ingest' });
assert.deepStrictEqual(serviceFromUrl('https://ingest.openre.stream/'), { service: 'openre', sub: 'ingest' });
const t = ssoTargets({});
const t = ssoTargets({});
-assert.ok(t.find(x => x.id === 'live') && t.find(x => x.id === 'tools') && t.find(x => x.id === 'community'), 'default targets');
+assert.ok(t.find(x => x.id === 'live') && t.find(x => x.id === 'tools') && t.find(x => x.id === 'community') && t.find(x => x.id === 'space'), 'default targets');
+assert.strictEqual(t.find(x => x.id === 'space').login, 'https://openvibe.space/auth/login?silent=1&next={next}');
assert.ok(t.every(x => x.login.includes('{next}')), 'every login hop carries the continuation');
assert.ok(t.every(x => x.login.includes('{next}')), 'every login hop carries the continuation');
-assert.deepStrictEqual(ssoTargets({ OV_SSO_TARGETS_DISABLED: 'games, community' }).map(x => x.id), ['live', 'tools'], 'disabled targets are dropped');
+assert.deepStrictEqual(ssoTargets({ OV_SSO_TARGETS_DISABLED: 'games, community' }).map(x => x.id), ['live', 'tools', 'space'], 'disabled targets are dropped');
+assert.strictEqual(safeNext('https://openvibe.space/s/general'), 'https://openvibe.space/s/general');
assert.strictEqual(safeNext('https://openvibe.live/@x?y=1'), 'https://openvibe.live/@x?y=1');
assert.strictEqual(safeNext('https://openvibe.live/@x?y=1'), 'https://openvibe.live/@x?y=1');
assert.strictEqual(safeNext('https://phish.example/'), '/', 'foreign hosts fall back to /');
assert.strictEqual(safeNext('https://phish.example/'), '/', 'foreign hosts fall back to /');
assert.strictEqual(safeNext('//evil'), '/');
assert.strictEqual(safeNext('//evil'), '/');
@@ -28,33 +28,36 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
@@ -28,33 +28,36 @@ const ev = (type, payload, actor = { type: 'user', id: MOD }) => ({ event_id: id
const chat = ev('chat.moderation.action', { action_id: 5, action_type: 'ban', scope_type: 'channel', scope_id: '327', actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: 'spam' } });
const chat = ev('chat.moderation.action', { action_id: 5, action_type: 'ban', scope_type: 'channel', scope_id: '327', actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: 'spam' } });
const community = ev('community.moderation.action', { action: 'paste.deleted', target: { type: 'paste', id: 'k3f9Qa', owner_subject: TARGET }, actor_subject: MOD, reason: 'doxxing', details: {} });
const community = ev('community.moderation.action', { action: 'paste.deleted', target: { type: 'paste', id: 'k3f9Qa', owner_subject: TARGET }, actor_subject: MOD, reason: 'doxxing', details: {} });
+ const space = ev('space.moderation.action', { action: 'thread.hidden', target: { type: 'thread', id: 'welcome', owner_subject: TARGET }, actor_subject: MOD, reason: 'spam', details: {} });
const tips = ev('tips.interaction.moderated', { interaction_id: 'tint_01JAB3C4D5E6F7G8H9J0K1MNPQ', creator: { type: 'user', id: TARGET }, action: 'hidden', by: 'moderator', moderation_state: 'hidden', cancelled_effects: ['tts'] }, { type: 'service', id: 'tips' });
const tips = ev('tips.interaction.moderated', { interaction_id: 'tint_01JAB3C4D5E6F7G8H9J0K1MNPQ', creator: { type: 'user', id: TARGET }, action: 'hidden', by: 'moderator', moderation_state: 'hidden', cancelled_effects: ['tts'] }, { type: 'service', id: 'tips' });
const live = ev('live.moderation.action', { action_id: 9, action_type: 'site_ban', scope_type: 'site', scope_id: null, actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: '=HYPERLINK("x")' } });
const live = ev('live.moderation.action', { action_id: 9, action_type: 'site_ban', scope_type: 'site', scope_id: null, actor_user_id: 1, actor_subject: MOD, target_user_id: 44, target_subject: TARGET, details: { reason: '=HYPERLINK("x")' } });
const billing = ev('billing.staff.action', { audit_id: 'sa_01JAB3C4D5E6F7G8H9J0K1MNPQ', action: 'cashout.approved', outcome: 'done', target: { type: 'cashout', id: 'co_1' }, reason: null, request_id: null, detail: { amount: 5 } });
const billing = ev('billing.staff.action', { audit_id: 'sa_01JAB3C4D5E6F7G8H9J0K1MNPQ', action: 'cashout.approved', outcome: 'done', target: { type: 'cashout', id: 'co_1' }, reason: null, request_id: null, detail: { amount: 5 } });
- for (const e of [chat, community, tips, billing, live]) assert.strictEqual((await consumer.apply(e)).outcome, 'recorded', e.event_type);
+ for (const e of [chat, community, space, tips, billing, live]) assert.strictEqual((await consumer.apply(e)).outcome, 'recorded', e.event_type);
assert.strictEqual((await consumer.apply(chat)).duplicate, true, 'a redelivery records nothing twice');
assert.strictEqual((await consumer.apply(chat)).duplicate, true, 'a redelivery records nothing twice');
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM notifications').get()).n, 0, 'audit events notify nobody');
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM notifications').get()).n, 0, 'audit events notify nobody');
const all = await audit.list({});
const all = await audit.list({});
- assert.strictEqual(all.items.length, 5);
+ assert.strictEqual(all.items.length, 6);
const liveRow = all.items.find((r) => r.service === 'live');
const liveRow = all.items.find((r) => r.service === 'live');
assert.deepStrictEqual([liveRow.action, liveRow.scope, liveRow.target_subject], ['site_ban', 'site', TARGET]);
assert.deepStrictEqual([liveRow.action, liveRow.scope, liveRow.target_subject], ['site_ban', 'site', TARGET]);
assert.strictEqual((await audit.list({ action: 'site_ban' })).items.length, 1);
assert.strictEqual((await audit.list({ action: 'site_ban' })).items.length, 1);
assert.strictEqual((await audit.list({ since: '2000-01-01', until: '2001-01-01' })).items.length, 0);
assert.strictEqual((await audit.list({ since: '2000-01-01', until: '2001-01-01' })).items.length, 0);
- assert.strictEqual((await audit.list({ since: '2000-01-01' })).items.length, 5);
+ assert.strictEqual((await audit.list({ since: '2000-01-01' })).items.length, 6);
const csv = require('../server/admin/moderation-audit').toCsv(all.items);
const csv = require('../server/admin/moderation-audit').toCsv(all.items);
assert.ok(csv.startsWith('id,occurred_at,service,action,'));
assert.ok(csv.startsWith('id,occurred_at,service,action,'));
assert.ok(csv.includes(`"'=HYPERLINK(""x"")"`), 'a formula-looking cell is text');
assert.ok(csv.includes(`"'=HYPERLINK(""x"")"`), 'a formula-looking cell is text');
- assert.strictEqual(csv.trim().split('\r\n').length, 6);
+ assert.strictEqual(csv.trim().split('\r\n').length, 7);
const byService = Object.fromEntries(all.items.map((r) => [r.service, r]));
const byService = Object.fromEntries(all.items.map((r) => [r.service, r]));
assert.deepStrictEqual([byService.chat.action, byService.chat.scope, byService.chat.target_subject, byService.chat.reason], ['ban', 'channel:327', TARGET, 'spam']);
assert.deepStrictEqual([byService.chat.action, byService.chat.scope, byService.chat.target_subject, byService.chat.reason], ['ban', 'channel:327', TARGET, 'spam']);
assert.deepStrictEqual([byService.community.action, byService.community.target_type, byService.community.target_id, byService.community.reason], ['paste.deleted', 'paste', 'k3f9Qa', 'doxxing']);
assert.deepStrictEqual([byService.community.action, byService.community.target_type, byService.community.target_id, byService.community.reason], ['paste.deleted', 'paste', 'k3f9Qa', 'doxxing']);
+ assert.deepStrictEqual([byService.space.action, byService.space.target_type, byService.space.target_id, byService.space.reason], ['thread.hidden', 'thread', 'welcome', 'spam']);
assert.strictEqual(byService.tips.action, 'interaction.hidden');
assert.strictEqual(byService.tips.action, 'interaction.hidden');
assert.strictEqual(byService.billing.actor_subject, MOD);
assert.strictEqual(byService.billing.actor_subject, MOD);
assert.strictEqual((await audit.list({ service: 'community' })).items.length, 1);
assert.strictEqual((await audit.list({ service: 'community' })).items.length, 1);
- assert.strictEqual((await audit.list({ actor: MOD })).items.length, 4, 'tips carries no moderator identity');
- assert.strictEqual((await audit.list({ target: TARGET })).items.length, 4);
+ assert.strictEqual((await audit.list({ service: 'space' })).items.length, 1);
+ assert.strictEqual((await audit.list({ actor: MOD })).items.length, 5, 'tips carries no moderator identity');
+ assert.strictEqual((await audit.list({ target: TARGET })).items.length, 5);
const page = await audit.list({ limit: 2 });
const page = await audit.list({ limit: 2 });
assert.strictEqual(page.items.length, 2);
assert.strictEqual(page.items.length, 2);
assert.strictEqual((await audit.list({ limit: 2, before: page.next })).items.length, 2, 'paged by id');
assert.strictEqual((await audit.list({ limit: 2, before: page.next })).items.length, 2, 'paged by id');
@@ -36,4 +36,6 @@ const pay = out.flatMap((a) => a.items).find((i) => /Payments/.test(i.item));
@@ -36,4 +36,6 @@ const pay = out.flatMap((a) => a.items).find((i) => /Payments/.test(i.item));
assert.deepStrictEqual([pay.status, pay.console], ['ready', true]);
assert.deepStrictEqual([pay.status, pay.console], ['ready', true]);
const community = out.flatMap((a) => a.items).find((i) => /Community moderation/.test(i.item));
const community = out.flatMap((a) => a.items).find((i) => /Community moderation/.test(i.item));
assert.deepStrictEqual([community.status, community.console], ['unknown', false], 'a gap is shown as a gap');
assert.deepStrictEqual([community.status, community.console], ['unknown', false], 'a gap is shown as a gap');
+const space = out.flatMap((a) => a.items).find((i) => /Space forum moderation/.test(i.item));
+assert.deepStrictEqual([space.service, space.status, space.console], ['space', 'unknown', false]);
console.log(`operator checklist: all checks passed (${items} items)`);
console.log(`operator checklist: all checks passed (${items} items)`);
@@ -17,6 +17,11 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principals-'));
@@ -17,6 +17,11 @@ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-principals-'));
const log = console.log; console.log = () => {};
const log = console.log; console.log = () => {};
const db = getDb();
const db = getDb();
console.log = log;
console.log = log;
+const spaceClient = await db.prepare("SELECT name, redirect_uris, is_first_party FROM oauth_clients WHERE client_id = 'space'").get();
+assert.strictEqual(spaceClient.name, 'OpenVibe.Space');
+assert.strictEqual(spaceClient.is_first_party, 1);
+assert.ok(JSON.parse(spaceClient.redirect_uris).includes('https://openvibe.space/auth/callback'));
+assert.ok(JSON.parse(spaceClient.redirect_uris).includes('http://localhost:4940/auth/callback'));
await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'media-secret' WHERE client_id = 'media'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'media-secret' WHERE client_id = 'media'").run();
@@ -161,6 +166,10 @@ const server = http.createServer(app);
@@ -161,6 +166,10 @@ const server = http.createServer(app);
const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
assert.strictEqual(com.status, 200, JSON.stringify(com.body));
assert.strictEqual(com.status, 200, JSON.stringify(com.body));
assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, account export and deletion, platform blocks, plus its community.profile module');
assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, account export and deletion, platform blocks, plus its community.profile module');
+ await db.prepare("UPDATE oauth_clients SET client_secret = 'space-secret' WHERE client_id = 'space'").run();
+ const spaceNetwork = await token({ client_id: 'space', client_secret: 'space-secret', audience: 'openvibe.network' });
+ assert.strictEqual(spaceNetwork.status, 200, JSON.stringify(spaceNetwork.body));
+ assert.deepStrictEqual(spaceNetwork.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read'], 'Space has forum identity, account data and block grants, with no Community profile module');
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${com.body.access_token}` });
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${com.body.access_token}` });
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.results['7'].username, 'payee');
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.results['7'].username, 'payee');
// Bot adds an operator by @username: the same projection, any case, a leading @ ignored; an unknown name is a 404.
// Bot adds an operator by @username: the same projection, any case, a leading @ ignored; an unknown name is a 404.
@@ -182,6 +191,13 @@ const server = http.createServer(app);
@@ -182,6 +191,13 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 403, 'Media holds no coin capability');
assert.strictEqual(r.status, 403, 'Media holds no coin capability');
const media = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.media' });
const media = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.media' });
assert.strictEqual(media.body.scope, 'media.object.upload', 'community may upload screenshot bytes to Media');
assert.strictEqual(media.body.scope, 'media.object.upload', 'community may upload screenshot bytes to Media');
+ for (const [audience, scope] of [['openvibe.media', 'media.object.upload'], ['openvibe.vip', 'vip.resource.policy.evaluate'], ['openvibe.events', 'events.event.publish events.event.read events.subscription.manage']]) {
+ const response = await token({ client_id: 'space', client_secret: 'space-secret', audience });
+ assert.strictEqual(response.status, 200, JSON.stringify(response.body));
+ assert.strictEqual(response.body.scope, scope, `Space's ${audience} grants`);
+ }
+ const spaceUpload = await require('../server/identity/principals').grantsFor(db, 'space', 'openvibe.media');
+ assert.deepStrictEqual(spaceUpload.find(g => g.capability === 'media.object.upload').namespaces, ['space']);
const cm = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.community' });
const cm = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.community' });
assert.deepStrictEqual(cm.body.scope.split(' '), ['community.comment.moderate', 'community.comment.write', 'community.paste.create', 'community.paste.moderate', 'community.paste.write', 'community.pulse.write']);
assert.deepStrictEqual(cm.body.scope.split(' '), ['community.comment.moderate', 'community.comment.write', 'community.paste.create', 'community.paste.moderate', 'community.paste.write', 'community.pulse.write']);
const tl = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.tools' });
const tl = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.tools' });
@@ -6,6 +6,7 @@ const http = require('http');
@@ -6,6 +6,7 @@ const http = require('http');
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { createEcosystemRegistry } = require('../server/registry/ecosystem');
const { createEcosystemRegistry } = require('../server/registry/ecosystem');
+assert.strictEqual(require('../server/registry/ecosystem').INTERNAL.space, 'http://127.0.0.1:4940');
(async () => {
(async () => {
// A running service answers readiness in the openvibe-shared/ready shape with a check that passed (liveness
// A running service answers readiness in the openvibe-shared/ready shape with a check that passed (liveness
@@ -29,6 +30,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -29,6 +30,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
let r = await get('/.well-known/openvibe');
let r = await get('/.well-known/openvibe');
assert.strictEqual(r.body.jwks_uri, 'https://openvibe.network/api/.well-known/jwks');
assert.strictEqual(r.body.jwks_uri, 'https://openvibe.network/api/.well-known/jwks');
assert.ok(r.body.services.some(s => s.id === 'community'));
assert.ok(r.body.services.some(s => s.id === 'community'));
+ assert.ok(r.body.services.some(s => s.id === 'space' && s.origin === 'https://openvibe.space'));
assert.strictEqual(r.cors, '*');
assert.strictEqual(r.cors, '*');
r = await get('/api/v1/registry/services');
r = await get('/api/v1/registry/services');
@@ -77,9 +79,10 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -77,9 +79,10 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
assert.strictEqual(inCat('events'), 'platform'); assert.strictEqual(inCat('ai'), 'platform'); assert.strictEqual(inCat('sources'), 'platform');
assert.strictEqual(inCat('events'), 'platform'); assert.strictEqual(inCat('ai'), 'platform'); assert.strictEqual(inCat('sources'), 'platform');
assert.strictEqual(inCat('sdk'), 'library'); assert.strictEqual(inCat('contracts'), 'library');
assert.strictEqual(inCat('sdk'), 'library'); assert.strictEqual(inCat('contracts'), 'library');
assert.strictEqual(inCat('examples'), 'repository');
assert.strictEqual(inCat('examples'), 'repository');
- // 'planned' = exposure placeholder: Contracts 0.83.0's media-hub and space, v0.87-v0.90's zone (charter only, nothing runs).
- // Watch left it in v0.108.0, Run and Services in v0.109.0 (they run, internal).
- assert.deepStrictEqual(cats.planned.services.map(x => x.id).sort(), ['media-hub', 'space', 'zone']);
+ // 'planned' = exposure placeholder: Contracts 0.83.0's media-hub, v0.87-v0.90's zone (charter only, nothing runs).
+ // Watch left it in v0.108.0, Run and Services in v0.109.0 (they run, internal); Space in v0.110.0 (live, a site).
+ assert.strictEqual(inCat('space'), 'site');
+ assert.deepStrictEqual(cats.planned.services.map(x => x.id).sort(), ['media-hub', 'zone']);
const liveRow = cats.site.services.find(x => x.id === 'live');
const liveRow = cats.site.services.find(x => x.id === 'live');
assert.strictEqual(liveRow.runtime, 'down'); assert.ok(liveRow.checked_at, 'rows carry the last check');
assert.strictEqual(liveRow.runtime, 'down'); assert.ok(liveRow.checked_at, 'rows carry the last check');
r = await get('/api/v1/registry/categories/library');
r = await get('/api/v1/registry/categories/library');
@@ -18,8 +18,8 @@ const PINNED = {
@@ -18,8 +18,8 @@ const PINNED = {
network: 'live', live: 'live', tools: 'live', media: 'live', games: 'live', community: 'live', events: 'live',
network: 'live', live: 'live', tools: 'live', media: 'live', games: 'live', community: 'live', events: 'live',
billing: 'live', codes: 'live', blog: 'live', wiki: 'live', sites: 'live',
billing: 'live', codes: 'live', blog: 'live', wiki: 'live', sites: 'live',
news: 'internal', reviews: 'internal', deals: 'internal', coupons: 'internal', trade: 'internal', host: 'live',
news: 'internal', reviews: 'internal', deals: 'internal', coupons: 'internal', trade: 'internal', host: 'live',
- tips: 'internal', vip: 'internal', openre: 'internal', search: 'live', sources: 'internal', chat: 'live', 'media-hub': 'placeholder', space: 'placeholder',
- ai: 'internal',
+ tips: 'internal', vip: 'internal', openre: 'internal', search: 'live', sources: 'internal', chat: 'live', 'media-hub': 'placeholder', space: 'live', // the forum, live since 2026-10-07 (contracts 0.110.0)
+ ai: 'live', // the public home and developer-app API at ai.openvibe.services since 2026-10-07 (contracts 0.110.0)
// openvibe-contracts v0.86-v0.90: Bot runs (its own exposure row); Zone is a charter only.
// openvibe-contracts v0.86-v0.90: Bot runs (its own exposure row); Zone is a charter only.
bot: 'live', zone: 'placeholder',
bot: 'live', zone: 'placeholder',
run: 'internal', // OpenVibe.Run runs on loopback since 2026-10-07 (contracts 0.109.0)
run: 'internal', // OpenVibe.Run runs on loopback since 2026-10-07 (contracts 0.109.0)
@@ -109,12 +109,12 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
@@ -109,12 +109,12 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
assert.strictEqual(sBy.live.origin, 'https://openvibe.live');
assert.strictEqual(sBy.live.origin, 'https://openvibe.live');
assert.strictEqual(sBy.shared.label, `not running (library, released v${require('openvibe-shared/package.json').version})`);
assert.strictEqual(sBy.shared.label, `not running (library, released v${require('openvibe-shared/package.json').version})`);
// Contracts 0.83.0 added two placeholder services (media-hub, space), v0.86-v0.90 Bot (live) and two more (zone, run),
// Contracts 0.83.0 added two placeholder services (media-hub, space), v0.86-v0.90 Bot (live) and two more (zone, run),
- // v0.95.0 Services; none is retired
- assert.deepStrictEqual(st.exposure_summary, { live: 16, internal: 13, library: 4, repository: 1, placeholder: 3, retired: 0 });
+ // v0.95.0 Services; Space and AI went live in v0.110.0; none is retired
+ assert.deepStrictEqual(st.exposure_summary, { live: 18, internal: 12, library: 4, repository: 1, placeholder: 2, retired: 0 });
// The page: no row for a service whose domain serves a placeholder shows a bare "Up".
// The page: no row for a service whose domain serves a placeholder shows a bare "Up".
const html = await fetch(base + '/status').then(x => x.text());
const html = await fetch(base + '/status').then(x => x.text());
- for (const id of PLACEHOLDER_DOMAINS.concat(['ai'])) {
+ for (const id of PLACEHOLDER_DOMAINS) {
const row = html.match(new RegExp(`<tr id="svc-${id}">[\\s\\S]*?</tr>`))[0];
const row = html.match(new RegExp(`<tr id="svc-${id}">[\\s\\S]*?</tr>`))[0];
assert.ok(!/>Up<\/span>/.test(row), `${id} row must not read a bare Up`);
assert.ok(!/>Up<\/span>/.test(row), `${id} row must not read a bare Up`);
assert.ok(row.includes('Up · loopback only'), `${id} row says loopback only`);
assert.ok(row.includes('Up · loopback only'), `${id} row says loopback only`);
@@ -122,10 +122,11 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
@@ -122,10 +122,11 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
}
}
assert.ok(/<tr id="svc-news">[\s\S]*?openvibe\.news: not this service yet/.test(html));
assert.ok(/<tr id="svc-news">[\s\S]*?openvibe\.news: not this service yet/.test(html));
assert.ok(/<tr id="svc-live">[\s\S]*?>Up<\/span>/.test(html), 'a public service still reads Up');
assert.ok(/<tr id="svc-live">[\s\S]*?>Up<\/span>/.test(html), 'a public service still reads Up');
+ assert.ok(/<tr id="svc-ai">[\s\S]*?>Up<\/span>/.test(html), 'AI serves its own public origin since contracts 0.110.0');
// 3. The Frame: the nav lists only sites whose domain serves the service; the rest are soon with a reason.
// 3. The Frame: the nav lists only sites whose domain serves the service; the rest are soon with a reason.
const open = SITES.filter(s => s.status === 'open').map(s => s.id);
const open = SITES.filter(s => s.status === 'open').map(s => s.id);
- assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'codes', 'blog', 'wiki', 'host', 'bot']); // bot: a site since openvibe-contracts 0.106.0; host: live since 0.107.0 (Stage B launched 2026-10-07)
+ assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'codes', 'blog', 'wiki', 'host', 'space', 'bot']); // by site position; bot: a site since openvibe-contracts 0.106.0; host: live since 0.107.0; space: live since 0.110.0 (the forum, 2026-10-07)
for (const s of SITES) {
for (const s of SITES) {
assert.ok(s.service && PINNED[s.service], `${s.id} names its service`);
assert.ok(s.service && PINNED[s.service], `${s.id} names its service`);
assert.strictEqual(s.status === 'open', PINNED[s.service] === 'live', `${s.id} nav status follows exposure`);
assert.strictEqual(s.status === 'open', PINNED[s.service] === 'live', `${s.id} nav status follows exposure`);
@@ -32,6 +32,7 @@ const BAD = [
@@ -32,6 +32,7 @@ const BAD = [
const GOOD = [
const GOOD = [
['/', '/'], ['/my#linked', '/my#linked'], ['/admin?tab=users', '/admin?tab=users'],
['/', '/'], ['/my#linked', '/my#linked'], ['/admin?tab=users', '/admin?tab=users'],
['https://openvibe.live/@x?y=1', 'https://openvibe.live/@x?y=1'],
['https://openvibe.live/@x?y=1', 'https://openvibe.live/@x?y=1'],
+ ['https://openvibe.space/s/general', 'https://openvibe.space/s/general'],
['https://json.openvibe.tools/', 'https://json.openvibe.tools/'],
['https://json.openvibe.tools/', 'https://json.openvibe.tools/'],
['https://openvibe.network/admin', 'https://openvibe.network/admin'],
['https://openvibe.network/admin', 'https://openvibe.network/admin'],
['https://ingest.openre.stream/', 'https://ingest.openre.stream/'],
['https://ingest.openre.stream/', 'https://ingest.openre.stream/'],
@@ -9,6 +9,7 @@ const cookieParser = require('cookie-parser');
@@ -9,6 +9,7 @@ const cookieParser = require('cookie-parser');
const { allowedOrigin, renderCheck, createSsoCheckRoute } = require('../server/auth/sso-check');
const { allowedOrigin, renderCheck, createSsoCheckRoute } = require('../server/auth/sso-check');
assert.strictEqual(allowedOrigin('https://openvibe.tools'), 'https://openvibe.tools');
assert.strictEqual(allowedOrigin('https://openvibe.tools'), 'https://openvibe.tools');
+assert.strictEqual(allowedOrigin('https://openvibe.space'), 'https://openvibe.space');
assert.strictEqual(allowedOrigin('https://json.openvibe.tools/'), 'https://json.openvibe.tools');
assert.strictEqual(allowedOrigin('https://json.openvibe.tools/'), 'https://json.openvibe.tools');
assert.strictEqual(allowedOrigin('https://play.openvibe.games'), 'https://play.openvibe.games');
assert.strictEqual(allowedOrigin('https://play.openvibe.games'), 'https://play.openvibe.games');
assert.strictEqual(allowedOrigin('https://ingest.openre.stream'), 'https://ingest.openre.stream');
assert.strictEqual(allowedOrigin('https://ingest.openre.stream'), 'https://ingest.openre.stream');
@@ -45,6 +45,8 @@ const publicCors = require('../server/public-cors');
@@ -45,6 +45,8 @@ const publicCors = require('../server/public-cors');
assert.strictEqual(page.split('class="up-sites"').length - 1, 1, 'one site filter');
assert.strictEqual(page.split('class="up-sites"').length - 1, 1, 'one site filter');
assert.ok(page.includes('<a href="/updates?site=bot">Bot</a>'), 'Bot is a site');
assert.ok(page.includes('<a href="/updates?site=bot">Bot</a>'), 'Bot is a site');
assert.strictEqual(serviceFor('bot'), 'bot');
assert.strictEqual(serviceFor('bot'), 'bot');
+ assert.strictEqual(serviceFor('openvibe.space'), 'space');
+ assert.ok(page.includes('<a href="/updates?site=space">Space</a>'));
const one = await fetch(`${base}/updates?site=openvibe.wiki`);
const one = await fetch(`${base}/updates?site=openvibe.wiki`);
assert.strictEqual(one.headers.get('x-robots-tag'), 'noindex, follow');
assert.strictEqual(one.headers.get('x-robots-tag'), 'noindex, follow');
assert.ok((await one.text()).includes('What shipped on OpenVibe.Wiki'));
assert.ok((await one.text()).includes('What shipped on OpenVibe.Wiki'));