openvibers/openvibe.network Commits

OpenVibe.Vision and OpenVibe.Exchange live (clients, unparked, grants; Design may call vision.ui.check); contracts v0.235.0 (#401)

OP OpenVibers committed 16 days ago · 1bf13974d5 · 10 files, +61 −59

Changes

ddiff --git a/deploy/nginx/parked-domains.conf b/deploy/nginx/parked-domains.conf
iindex cf87b630..50148c63 100644
---- a/deploy/nginx/parked-domains.conf
++++ b/deploy/nginx/parked-domains.conf
@@@ -15,6 +15,8 @@
# realtime.openvibe.network: OpenVibe.Realtime is closed; delivery is OpenVibe.Events.
# openvibe.report left on 2026-10-28: OpenVibe.Report serves it (OpenVibe.Report deploy/nginx/openvibe.report.conf).
# openvibe.domains left on 2026-10-28: OpenVibe.Domains serves it (OpenVibe.Domains deploy/nginx/openvibe.domains.conf).
++# openvibe.vision left on 2026-10-28: OpenVibe.Vision serves it (OpenVibe.Vision deploy/nginx/openvibe.vision.conf).
++# openvibe.exchange left on 2026-10-28: OpenVibe.Exchange serves it (OpenVibe.Exchange deploy/nginx/openvibe.exchange.conf).
# 2026-10-28: the thirteen domains bought that day (open.shoes and twelve openvibe.*) are parked here, each sent to
# where its capability lives today, until its room's own vhost takes the name (plan: OpenVibe_New_Domains_Plan).
#
@@@ -81,27 +83,7 @@ server {
return 302 https://openvibe.deals/search?q=shoes;
}
--# openvibe.vision → AI, whose models read pictures
--server {
-- listen 80;
-- listen [::]:80;
-- server_name openvibe.vision www.openvibe.vision;
-- return 302 https://ai.openvibe.services/;
--}
--
--server {
-- listen 443 ssl;
-- listen [::]:443 ssl;
-- http2 on;
-- server_name openvibe.vision www.openvibe.vision;
-- ssl_certificate /etc/letsencrypt/live/openvibe.vision/fullchain.pem;
-- ssl_certificate_key /etc/letsencrypt/live/openvibe.vision/privkey.pem;
-- add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
-- add_header X-Content-Type-Options "nosniff" always;
-- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
-- access_log off;
-- return 302 https://ai.openvibe.services/;
--}
++# openvibe.vision is served by OpenVibe.Vision since 2026-10-28 (its own vhost, deploy/nginx/openvibe.vision.conf).
# openvibe.domains is served by OpenVibe.Domains since 2026-10-28 (its own vhost, deploy/nginx/openvibe.domains.conf).
@@@ -173,27 +155,7 @@ server {
return 302 https://openvibe.live/;
}
--# openvibe.exchange → Services, the developer hub
--server {
-- listen 80;
-- listen [::]:80;
-- server_name openvibe.exchange www.openvibe.exchange;
-- return 302 https://openvibe.services/;
--}
--
--server {
-- listen 443 ssl;
-- listen [::]:443 ssl;
-- http2 on;
-- server_name openvibe.exchange www.openvibe.exchange;
-- ssl_certificate /etc/letsencrypt/live/openvibe.exchange/fullchain.pem;
-- ssl_certificate_key /etc/letsencrypt/live/openvibe.exchange/privkey.pem;
-- add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
-- add_header X-Content-Type-Options "nosniff" always;
-- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
-- access_log off;
-- return 302 https://openvibe.services/;
--}
++# openvibe.exchange is served by OpenVibe.Exchange since 2026-10-28 (its own vhost, deploy/nginx/openvibe.exchange.conf).
# openvibe.cam → going live with your camera on Live
server {
ddiff --git a/package-lock.json b/package-lock.json
iindex 7849c7d9..53822bd9 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,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.234.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0",
@@@ -1320,9 +1320,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.234.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.0",
-- "integrity": "sha512-qCv+UuC1Q/megqkkLu69f5VBivuZao6jIztYmcj9e4KBT2cBe8zNykkOaQJS/Uu6weU+gy6NVHZ4w1K3SKM7FA==",
++ "version": "0.235.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
++ "integrity": "sha512-Rfmy2sDZOO+fegEPBweQvCKlamkYwOXGyr1M80VkYHXHl8gg7CnyDL7HrbGwuXG3LUcPqzhAMYeeB//jKMtzaw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex a00c7d98..e3273633 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,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.234.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
"pg": "^8.23.0",
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 76c818f6..73d7f0a4 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -130,6 +130,10 @@ async function seedDb(db, { log = console } = {}) {
{ client_id: 'domains', name: 'OpenVibe.Domains', redirect_uris: ['https://openvibe.domains/auth/callback'] },
// The open meme database: a signed-in front for votes, captions and reports.
{ client_id: 'meme', name: 'OpenVibe.Meme', redirect_uris: ['https://openvibe.meme/auth/callback'] },
++ // Ask a picture, compare models and check a page's UI (plan 2026-10-28 §5.8); Design's Arena calls its UI check.
++ { client_id: 'vision', name: 'OpenVibe.Vision', redirect_uris: ['https://openvibe.vision/auth/callback'] },
++ // Free and swap listings with a permission passport, a sandbox and one-tap revoke (the Exchange room).
++ { client_id: 'exchange', name: 'OpenVibe.Exchange', redirect_uris: ['https://openvibe.exchange/auth/callback'] },
{ client_id: 'work', name: 'OpenVibe.Work', redirect_uris: ['https://openvibe.work/auth/callback'] },
// The shared quest log, listings people post, and Watch's public site (plan T18 step 8, T19).
{ client_id: 'quest', name: 'OpenVibe.Quest', redirect_uris: ['https://openvibe.quest/auth/callback'] },
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 5a5ab0bf..835ca268 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -143,7 +143,7 @@ const DEFAULT_GRANTS = [
// account-data consumer and subscriptions shipped first in Reviews#77). Host since 2026-10-19 (the console's sealed hosting
// tokens and actions, benchmarks' submissions; Host#120, subscribed first through Network#284).
...['live', 'chat', 'community', 'media', 'games', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'media-hub',
-- 'deals', 'trade', 'tips', 'vip', 'watch', 'services', 'wiki', 'blog', 'bot', 'inventory', 'codes', 'play', 'space', 'billing', 'reviews', 'host', 'domains', 'meme'].flatMap((svc) => [
++ 'deals', 'trade', 'tips', 'vip', 'watch', 'services', 'wiki', 'blog', 'bot', 'inventory', 'codes', 'play', 'space', 'billing', 'reviews', 'host', 'domains', 'meme', 'vision', 'exchange'].flatMap((svc) => [
[svc, 'network.account.export.contribute', SELF_AUDIENCE, []],
[svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],
]),
@@@ -566,6 +566,17 @@ const DEFAULT_GRANTS = [
// account-event subscriptions at boot; its export and deletion grants are with the other services' above.
['meme', 'search.document.write', 'openvibe.search', []],
['meme', 'events.subscription.manage', 'openvibe.events', []],
++ // OpenVibe.Vision (contracts 0.235.0): model calls through AI's OpenAI-compatible endpoint (workflow ai.complete),
++ // its account-event subscriptions, and the export/deletion pair above. It has no public records, so no Search grant.
++ ['vision', 'ai.run.create', 'openvibe.ai', ['ai.complete']],
++ ['vision', 'events.subscription.manage', 'openvibe.events', []],
++ // OpenVibe.Exchange (contracts 0.235.0): its public listings in Search through the owner API (owner exchange), and
++ // its account-event subscriptions; its export/deletion pair is above. v1 writes no grant and runs no sandbox code.
++ ['exchange', 'search.document.write', 'openvibe.search', []],
++ ['exchange', 'events.subscription.manage', 'openvibe.events', []],
++ // OpenVibe.Design's Arena runs Vision's UI check (contracts 0.235.0 vision.ui.check, first-party). Vision 0.1.0's
++ // source still labels the route's guard vision.ui_check; its service must use the contract id before Arena calls it.
++ ['design', 'vision.ui.check', 'openvibe.vision', []],
];
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 756c3de4..309d4a24 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -93,11 +93,11 @@ const server = http.createServer(app);
// ── Export ──
const seeded = await accountData.expectedServices(db, 'network.account.export.contribute');
-- assert.deepStrictEqual(seeded, ['actor', 'billing', 'blog', 'bot', 'chat', 'codes', 'community', 'deals', 'domains', 'food', 'games', 'help', 'host', 'inventory', 'live', 'media', 'media-hub', 'meme', 'play', 'quest', 'rent', 'reviews', 'services', 'space', 'tips', 'trade', 'vip', 'watch', 'wiki', 'work'],
-- 'the holders of the contribute grant: the skeleton services answer too since 2026-10-08 and the older ones since 2026-10-09 (openvibe-sdk/account-data); Codes and Play since 2026-10-14 (run tasks; scores and unlocks); Space again since 2026-10-16 (its spaces); Billing since 2026-10-17 (money records); Reviews since 2026-10-17 (members\' reviews); Host since 2026-10-19 (the console and benchmarks); Meme since 2026-10-28 (votes, captions and reports)');
++ assert.deepStrictEqual(seeded, ['actor', 'billing', 'blog', 'bot', 'chat', 'codes', 'community', 'deals', 'domains', 'exchange', 'food', 'games', 'help', 'host', 'inventory', 'live', 'media', 'media-hub', 'meme', 'play', 'quest', 'rent', 'reviews', 'services', 'space', 'tips', 'trade', 'vip', 'vision', 'watch', 'wiki', 'work'],
++ 'the holders of the contribute grant: the skeleton services answer too since 2026-10-08 and the older ones since 2026-10-09 (openvibe-sdk/account-data); Codes and Play since 2026-10-14 (run tasks; scores and unlocks); Space again since 2026-10-16 (its spaces); Billing since 2026-10-17 (money records); Reviews since 2026-10-17 (members\' reviews); Host since 2026-10-19 (the console and benchmarks); Meme since 2026-10-28 (votes, captions and reports), Vision and Exchange since 2026-10-28 (answers and checks; listings, installs and offers)');
// The scenarios below follow five services; the newer ones are set aside here, exactly as an operator would
// revoke a service's grant (the same rows, the same revoked_at).
-- for (const svc of ['actor', 'billing', 'blog', 'bot', 'codes', 'deals', 'domains', 'food', 'help', 'host', 'inventory', 'media-hub', 'meme', 'play', 'quest', 'rent', 'reviews', 'services', 'space', 'tips', 'trade', 'vip', 'watch', 'wiki', 'work']) {
++ for (const svc of ['actor', 'billing', 'blog', 'bot', 'codes', 'deals', 'domains', 'exchange', 'food', 'help', 'host', 'inventory', 'media-hub', 'meme', 'play', 'quest', 'rent', 'reviews', 'services', 'space', 'tips', 'trade', 'vip', 'vision', 'watch', 'wiki', 'work']) {
for (const cap of ['network.account.export.contribute', 'network.account.deletion.confirm']) {
await db.prepare('UPDATE principal_grants SET revoked_at = ov_now() WHERE client_id = ? AND capability = ? AND revoked_at IS NULL').run(svc, cap);
}
ddiff --git a/test/frame.test.js b/test/frame.test.js
iindex 99e05e56..016eaf95 100644
---- a/test/frame.test.js
++++ b/test/frame.test.js
@@@ -14,7 +14,7 @@ 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.filter(n => !n.of).map(n => n.id), ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme'], 'cold start follows the base order');
++assert.deepEqual(p.nav.filter(n => !n.of).map(n => n.id), ['live', 'tools', 'space', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange'], 'cold start follows the base order');
// The launcher: every open site in one of the groups, and the products other services serve on their own domains
// right after their site (Pics and Download after Video, Homes after Rent).
assert.deepStrictEqual(p.groups.map(g => g.name), ['Streams & places', 'Games & laughs', 'News & answers', 'Make & build', 'Shop & swap', 'People & you', 'For developers'], 'the six neighbourhoods (plan 2026-10-28 §3.1), the building blocks last');
@@@ -26,9 +26,9 @@ assert.ok(at('pics') === at('media-hub') + 1 && at('homes') === at('rent') + 1,
assert.deepStrictEqual([p.nav[at('media-hub')].icon, p.nav[at('media-hub')].group, p.nav[at('homes')].group, p.nav[at('tools')].group], ['video', 'streams', 'shop', 'make'], 'Video draws the video icon (there is no media-hub icon)');
assert.deepStrictEqual(['actor', 'bot', 'run', 'zone'].map((id) => p.nav[at(id)].group), ['make', 'make', 'build', 'build'], 'agents and robots for people, compute and storage for developers');
// Games together, items with the account, Alerts with the building blocks, files beside Video (each when its site is open).
--[['games', 'games'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games']]
++[['games', 'games'], ['quest', 'games'], ['inventory', 'people'], ['watch', 'build'], ['download', 'streams'], ['search', 'answers'], ['chat', 'people'], ['domains', 'make'], ['report', 'answers'], ['design', 'make'], ['today', 'answers'], ['surf', 'games'], ['meme', 'games'], ['vision', 'answers'], ['exchange', 'shop']]
.filter(([id]) => at(id) >= 0).forEach(([id, group]) => assert.strictEqual(p.nav[at(id)].group, group, `${id} is in ${group}`));
--assert.strictEqual(p.nav.length, 46); // Domains since contracts 0.226.0, Report since 0.227.0, Design since 0.228.0, Today since 0.230.0, Surf since 0.231.0, Meme since 0.234.0
++assert.strictEqual(p.nav.length, 48); // Domains since contracts 0.226.0, Report since 0.227.0, Design since 0.228.0, Today since 0.230.0, Surf since 0.231.0, Meme since 0.234.0, Vision and Exchange since 0.235.0
assert.deepStrictEqual([p.nav[at('stream')].name, p.nav[at('pics')].name], ['Restream', 'Pics'], 'OpenRestream is "Restream" among the menu\'s short names (owner, 2026-10-28)');
assert.strictEqual(p.nav.find(n => n.id === 'space').url, 'https://openvibe.space/', 'Space keeps its own home (the forum returned to Community, 0.118.0)');
assert.strictEqual(p.nav.find(n => n.id === 'community').url, 'https://openvibe.community/', 'Community: the forum, pastes and Pulse');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex e7f5c599..e7b89609 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,8 +12,8 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 180, // 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
-- htmlBrotliKB: 32, // 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
++ htmlRawKB: 184, // 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
++ htmlBrotliKB: 33, // 32.0 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
jsRawKB: 311, // 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3
jsBrotliKB: 78, // 77 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.3 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 70.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 68.0 with shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 64.5 with shared 4.11.0's navbar; 60.3 with boost.js (3.8); 53.9
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 11077fc6..5f8ba641 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -101,6 +101,31 @@ const server = http.createServer(app);
t = await token({ client_id: 'meme', client_secret: 'meme-secret', audience: 'openvibe.network' });
assert.deepStrictEqual(t.body.scope.split(' '), ['network.account.deletion.confirm', 'network.account.export.contribute'], 'Meme answers account export and deletion, and holds no identity.subject.resolve');
++ // OpenVibe.Vision and OpenVibe.Exchange (contracts 0.235.0): both answer account export and deletion and create
++ // their two account-event subscriptions at boot; Vision runs AI workflow ai.complete, Exchange feeds Search.
++ for (const [client, secret] of [['vision', 'vision-secret'], ['exchange', 'exchange-secret']]) {
++ await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(secret, client);
++ t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.network' });
++ assert.deepStrictEqual(t.body.scope.split(' '), ['network.account.deletion.confirm', 'network.account.export.contribute'], `${client} answers account export and deletion, and holds no identity.subject.resolve`);
++ t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.events' });
++ assert.strictEqual(t.body.scope, 'events.subscription.manage', `${client} subscribes to the account events at boot`);
++ }
++ t = await token({ client_id: 'vision', client_secret: 'vision-secret', audience: 'openvibe.ai' });
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ const visionAiClaims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.ai' });
++ assert.ok(visionAiClaims.ok, visionAiClaims.reason);
++ assert.deepStrictEqual([visionAiClaims.claims.cap, visionAiClaims.claims.ns], [['ai.run.create'], ['ai.complete']], 'Vision runs AI workflow ai.complete through the OpenAI-compatible endpoint');
++ t = await token({ client_id: 'vision', client_secret: 'vision-secret', audience: 'openvibe.search' });
++ assert.strictEqual(t.status, 400, 'Vision has no public records, so no Search grant');
++ t = await token({ client_id: 'exchange', client_secret: 'exchange-secret', audience: 'openvibe.search' });
++ assert.strictEqual(t.body.scope, 'search.document.write', 'Exchange indexes its public listings in Search');
++ t = await token({ client_id: 'exchange', client_secret: 'exchange-secret', audience: 'openvibe.ai' });
++ assert.strictEqual(t.status, 400, 'Exchange holds no AI capability in v1');
++ await db.prepare("UPDATE oauth_clients SET client_secret = 'design-secret' WHERE client_id = 'design'").run();
++ t = await token({ client_id: 'design', client_secret: 'design-secret', audience: 'openvibe.vision' });
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ assert.strictEqual(t.body.scope, 'vision.ui.check', "Design's Arena runs Vision's UI check");
++
t = await token({ client_id: 'live', client_secret: 'wrong', audience: 'openvibe.network' });
assert.strictEqual(t.status, 401); assert.strictEqual(t.body.error, 'invalid_client');
t = await token({ client_id: 'live', client_secret: 'live-secret' });
ddiff --git a/test/registry-exposure.test.js b/test/registry-exposure.test.js
iindex 8bcea52b..3c046470 100644
---- a/test/registry-exposure.test.js
++++ b/test/registry-exposure.test.js
@@@ -17,7 +17,7 @@ const { SITES } = require('../server/frame/sites');
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: 'retired', // Sites retired 2026-10-14 (contracts 0.181.0)
-- news: 'live', reviews: 'live', deals: 'live', coupons: 'live', trade: 'live', host: 'live', domains: 'live', report: 'live', design: 'live', today: 'live', surf: 'live', meme: 'live', // News, Reviews, Coupons, Trade live since contracts 0.125.0; Domains since 0.226.0, Report 0.227.0, Design 0.228.0, Meme since 0.234.0
++ news: 'live', reviews: 'live', deals: 'live', coupons: 'live', trade: 'live', host: 'live', domains: 'live', report: 'live', design: 'live', today: 'live', surf: 'live', meme: 'live', vision: 'live', exchange: 'live', // News, Reviews, Coupons, Trade live since contracts 0.125.0; Domains since 0.226.0, Report 0.227.0, Design 0.228.0, Meme since 0.234.0, Vision and Exchange since 0.235.0
tips: 'live', vip: 'live', openre: 'live', search: 'live', sources: 'internal', chat: 'live', 'media-hub': 'live', space: 'live', // a home and redirects since the forum returned to Community (contracts 0.118.0)
// tips, vip: their own sites at openvibe.tips and openvibe.vip since contracts 0.200.0 (2026-10-17)
ai: 'live', // the public home and developer-app API at ai.openvibe.services since 2026-10-07 (contracts 0.110.0)
@@@ -121,7 +121,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
// v0.95.0 Services; Space and AI went live in v0.110.0; Food, Help and Work in v0.116.0; Quest, Rent and Watch in v0.117.0;
// Inventory in v0.122.0; none is retired
// News, Reviews, Coupons, Trade and OpenRestream in v0.125.0; Run in v0.141.0
-- assert.deepStrictEqual(st.exposure_summary, { live: 44, internal: 1, library: 4, repository: 1, placeholder: 0, retired: 1 }); // Sites retired (contracts 0.181.0); Tips and VIP live (0.200.0); Design live (0.228.0); Meme live (0.234.0)
++ assert.deepStrictEqual(st.exposure_summary, { live: 46, internal: 1, library: 4, repository: 1, placeholder: 0, retired: 1 }); // Sites retired (contracts 0.181.0); Tips and VIP live (0.200.0); Design live (0.228.0); Meme live (0.234.0); Vision and Exchange live (0.235.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());
@@@ -137,7 +137,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
// 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', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme']); // domains: open since 0.226.0, report since 0.227.0, design since 0.228.0 (the 2026-10-28 rooms); tips, vip: open since 0.200.0; zone: a site since 0.173.0; play: a site since 0.172.0; run: a site since 0.141.0; events, ai and search: sites since 0.135.0; news, reviews, trade, coupons and stream (OpenRestream): open since 0.125.0; // 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)
++ assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'chat', 'services', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons', 'stream', 'space', 'media-hub', 'bot', 'codes', 'actor', 'food', 'help', 'work', 'quest', 'rent', 'watch', 'inventory', 'events', 'ai', 'search', 'run', 'play', 'zone', 'domains', 'report', 'design', 'today', 'surf', 'meme', 'vision', 'exchange']); // domains: open since 0.226.0, report since 0.227.0, design since 0.228.0 (the 2026-10-28 rooms), vision and exchange since 0.235.0; tips, vip: open since 0.200.0; zone: a site since 0.173.0; play: a site since 0.172.0; run: a site since 0.141.0; events, ai and search: sites since 0.135.0; news, reviews, trade, coupons and stream (OpenRestream): open since 0.125.0; // 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`);