openvibers/openvibe.network Commits

Network: OpenVibe.Space signs in, publishes and is a site; contracts 0.110.0 (Space and AI live) (#75)

OP OpenVibers committed 7 Oct 2026 · ac35767dae · 32 files, +105 −46

## For Opus

- [server/db/database.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/db/database.js:91): Seeded Space’s OAuth client and port 4940 localhost callback.
- [server/identity/principals.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/identity/principals.js:47): Added Space’s identity, Media, Events, VIP, blocks, export, and deletion grants; no `network.modules.*` grant.
- [server/auth/sso-targets.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/auth/sso-targets.js:32): Added Space to sign-in and sign-out fanout.
- [server/auth/sso-owned.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/auth/sso-owned.js:5): Trusted Space for cross-site redirects and FedCM.
- [public/js/login.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/public/js/login.js:412): Allowed Space in the browser’s return-URL guard.
- [public/sso-fanout.html](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/public/sso-fanout.html:69): Allowed Space in the fanout page’s return-URL guard.
- [server/frame/service.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/frame/service.js:25): Ranked Space as the forum destination while retaining Community for its other products.
- [server/registry/exposure.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/registry/exposure.js:62): Exposed Space as open while the pinned Contracts manifest still says placeholder.
- [server/registry/ecosystem.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/registry/ecosystem.js:49): Added port 4940 as Space’s health-check fallback.
- [server/admin/moderation-audit.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/admin/moderation-audit.js:30): Subscribed to and recorded `space.moderation.action`.
- [server/admin/operator-checklist.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/admin/operator-checklist.js:43): Added Space forum moderation.
- [server/admin/staff-api.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/admin/staff-api.js:20): Mapped Space moderators to discussion staff.
- [server/updates/routes.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/updates/routes.js:72): Added Space to update filters.
- [server/history/routes.js](/tmp/claude-1000/-home-workstation-orca-workspaces-OpenVibe-Live-seadragon/070220df-455a-45d2-b679-8c15af458e9a/scratchpad/n74-space/server/history/routes.js:19): Labeled

Changes

ddiff --git a/README.md b/README.md
iindex b8f360cd..f93a8985 100644
---- a/README.md
++++ b/README.md
@@@ -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 (PostgreSQL
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`
workflow), OpenVibe.Blog (the changelog feed it proxies)
ddiff --git a/package-lock.json b/package-lock.json
iindex 1b80abf4..e77a6bef 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 88475ca8..e50fe528 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/index.html b/public/index.html
iindex 217ecf05..575c16c3 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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
ddiff --git a/public/js/login.js b/public/js/login.js
iindex 2038a0f1..48e5fa3b 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -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 ours
function safeReturnUrl(raw) {
const s = String(raw == null ? '' : raw);
ddiff --git a/public/js/my/security.js b/public/js/my/security.js
iindex 6dd693d2..767bb8bd 100644
---- a/public/js/my/security.js
++++ b/public/js/my/security.js
@@@ -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' },
};
ddiff --git a/public/sso-fanout.html b/public/sso-fanout.html
iindex 1fee9f35..2e0a8cfd 100644
---- a/public/sso-fanout.html
++++ b/public/sso-fanout.html
@@@ -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 ours
function safeNext(raw) {
const s = String(raw == null ? '' : raw);
ddiff --git a/server/admin/moderation-audit.js b/server/admin/moderation-audit.js
iindex 777f9cd3..1f16270c 100644
---- a/server/admin/moderation-audit.js
++++ b/server/admin/moderation-audit.js
@@@ -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': {
ddiff --git a/server/admin/operator-checklist.js b/server/admin/operator-checklist.js
iindex 76e3aff0..a138b779 100644
---- a/server/admin/operator-checklist.js
++++ b/server/admin/operator-checklist.js
@@@ -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' },
ddiff --git a/server/admin/staff-api.js b/server/admin/staff-api.js
iindex 33b3f3c4..a1b997d0 100644
---- a/server/admin/staff-api.js
++++ b/server/admin/staff-api.js
@@@ -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;
ddiff --git a/server/auth/sso-owned.js b/server/auth/sso-owned.js
iindex 6cb3851b..d61a2f7e 100644
---- a/server/auth/sso-owned.js
++++ b/server/auth/sso-owned.js
@@@ -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));
ddiff --git a/server/auth/sso-targets.js b/server/auth/sso-targets.js
iindex 3cd9fdf0..f8dd0b46 100644
---- a/server/auth/sso-targets.js
++++ b/server/auth/sso-targets.js
@@@ -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}' },
];
/**
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 438dfe55..2879349a 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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 }); }
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 95ef83c4..793c2e7c 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -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
ddiff --git a/server/history/routes.js b/server/history/routes.js
iindex 4acbb54e..61033483 100644
---- a/server/history/routes.js
++++ b/server/history/routes.js
@@@ -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 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',
};
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 59ac205e..29351164 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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.
ddiff --git a/server/index.js b/server/index.js
iindex 85bdfde8..47a6db2b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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
]) {
ddiff --git a/server/updates/routes.js b/server/updates/routes.js
iindex 27994680..74920833 100644
---- a/server/updates/routes.js
++++ b/server/updates/routes.js
@@@ -69,7 +69,7 @@ function createUpdatesRoutes({ blogUrl = 'http://127.0.0.1:4810', fetchImpl = gl
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 nameOf = (id) => NAMES[id] || (id ? id.charAt(0).toUpperCase() + id.slice(1) : '');
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 8544d1c3..2575e756 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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' });
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex 8ecebb58..1baa8317 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -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');
ddiff --git a/test/fedcm.test.js b/test/fedcm.test.js
iindex dc10b37a..1d5f5b3d 100644
---- a/test/fedcm.test.js
++++ b/test/fedcm.test.js
@@@ -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');
ddiff --git a/test/frame.test.js b/test/frame.test.js
iindex 3a1c643c..e9be3531 100644
---- a/test/frame.test.js
++++ b/test/frame.test.js
@@@ -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)');
ddiff --git a/test/grants-admin.test.js b/test/grants-admin.test.js
iindex b9ba0ad9..d7844c5a 100644
---- a/test/grants-admin.test.js
++++ b/test/grants-admin.test.js
@@@ -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);
ddiff --git a/test/history-sso.test.js b/test/history-sso.test.js
iindex 7053a357..0b45ba7f 100644
---- a/test/history-sso.test.js
++++ b/test/history-sso.test.js
@@@ -15,12 +15,15 @@ assert.strictEqual(allowedUrl('https://evil.example.com/'), null, 'off-network U
assert.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'), '/');
ddiff --git a/test/moderation-audit.test.js b/test/moderation-audit.test.js
iindex f2b86081..8b97a2bf 100644
---- a/test/moderation-audit.test.js
++++ b/test/moderation-audit.test.js
@@@ -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 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');
ddiff --git a/test/operator-checklist.test.js b/test/operator-checklist.test.js
iindex 4c28b31c..62a1936f 100644
---- a/test/operator-checklist.test.js
++++ b/test/operator-checklist.test.js
@@@ -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)`);
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex df452189..2e239f6a 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -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' });
ddiff --git a/test/registry-ecosystem.test.js b/test/registry-ecosystem.test.js
iindex c9e435a5..0f7a0aa8 100644
---- a/test/registry-ecosystem.test.js
++++ b/test/registry-ecosystem.test.js
@@@ -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');
ddiff --git a/test/registry-exposure.test.js b/test/registry-exposure.test.js
iindex 82923797..d84752a6 100644
---- a/test/registry-exposure.test.js
++++ b/test/registry-exposure.test.js
@@@ -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: [], ch
assert.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`);
ddiff --git a/test/security-redirects.test.js b/test/security-redirects.test.js
iindex 0b169e94..72bcb195 100644
---- a/test/security-redirects.test.js
++++ b/test/security-redirects.test.js
@@@ -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/'],
ddiff --git a/test/sso-check.test.js b/test/sso-check.test.js
iindex b51ba629..3510a9b5 100644
---- a/test/sso-check.test.js
++++ b/test/sso-check.test.js
@@@ -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');
ddiff --git a/test/updates.test.js b/test/updates.test.js
iindex 46e3d35a..18c11090 100644
---- a/test/updates.test.js
++++ b/test/updates.test.js
@@@ -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'));