openvibers/openvibe.services Commits

The openvibe.services home shows what you can build today, with working examples, quick-start code that runs as written, the platform in numbers, plain-unit limits and the questions developers ask (#90)

OP OpenVibers committed 22 days ago · e5c08713af · 7 files, +279 −29

Changes

ddiff --git a/public/css/services.css b/public/css/services.css
iindex ba71541..c17c56a 100644
---- a/public/css/services.css
++++ b/public/css/services.css
@@@ -79,3 +79,17 @@ meter.headroom { width: 8rem; max-width: 100%; vertical-align: middle; }
.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
.services-hero-code { margin: 0; padding: 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); font-size: 13px; line-height: 1.6; overflow: auto; }
++
++/* The home page: the platform in numbers, quick-start samples one under another (their lines are long), and the FAQ. */
++.page .svc-num { margin-top: 28px; }
++.svc-num ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr)); }
++.svc-num a { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; }
++.svc-num a:hover, .svc-num a:focus-visible { border-color: var(--accent, #3b82f6); }
++.svc-num b { font-size: 26px; font-weight: 800; letter-spacing: -0.5px; line-height: 1.15; }
++.svc-num span { font-size: 13px; line-height: 1.35; color: var(--text-secondary, #96a7c2); }
++#quick-start .sc-code { grid-template-columns: minmax(0, 1fr); }
++#build .sc-note code { overflow-wrap: anywhere; }
++.svc-faq { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); }
++.svc-faq > div { padding: 16px 18px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); }
++.svc-faq h3 { margin: 0 0 6px; font-size: 15.5px; line-height: 1.35; }
++.svc-faq p { margin: 0; font-size: 14px; line-height: 1.55; color: var(--text-secondary, #96a7c2); }
ddiff --git a/server/http/home.js b/server/http/home.js
nnew file mode 100644
iindex 0000000..b29ebab
---- /dev/null
++++ b/server/http/home.js
@@@ -0,0 +1,146 @@
++'use strict';
++/**
++ * The home page's content beyond the kit's generic sections: the platform in numbers, what people build on it (each
++ * card is a working example in OpenVibe.Examples or a generated reference page), quick-start code checked against the
++ * pinned SDK, and the questions developers ask. Every number is counted from the pinned contracts or read from
++ * OpenVibe.Network's public status within the last minute; nothing here is typed in as a figure.
++ */
++const contracts = require('openvibe-contracts');
++const showcase = require('openvibe-shared/showcase');
++
++const esc = showcase.esc;
++const EXAMPLES = 'https://github.com/OpenVibers/OpenVibe.Examples';
++const example = (name) => `${EXAMPLES}/tree/main/examples/${name}`;
++
++// ── Services up: Network's public status (GET /api/v1/status, the registry's health poll), read in the background at
++// most once a minute and never awaited by a page. A count is shown only when at least half the live services were
++// checked; otherwise the number is left out.
++function createNetworkStatus({ url, enabled = true, fetchImpl = globalThis.fetch, ttlMs = 60_000, timeoutMs = 2000 } = {}) {
++ let value = null;
++ let at = 0;
++ let inflight = null;
++ async function refresh() {
++ if (inflight) return inflight;
++ inflight = (async () => {
++ try {
++ const res = await fetchImpl(`${String(url).replace(/\/+$/, '')}/api/v1/status`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
++ if (!res.ok) return value;
++ const body = await res.json();
++ const live = (Array.isArray(body.services) ? body.services : []).filter((s) => s && s.exposure && s.exposure.state === 'live');
++ const known = live.filter((s) => s.status && s.status !== 'unknown');
++ value = known.length && known.length * 2 >= live.length ? { up: known.filter((s) => s.status === 'up').length, checked: known.length } : null;
++ } catch { /* keep the last answer */ }
++ at = Date.now();
++ return value;
++ })().finally(() => { inflight = null; });
++ return inflight;
++ }
++ return {
++ peek() { if (enabled && Date.now() - at > ttlMs && !inflight) refresh().catch(() => {}); return value; }, // floating-ok: refresh catches its own errors
++ refresh,
++ /** Tests. */
++ _set(v) { value = v; at = Date.now(); },
++ };
++}
++
++const publicCaps = (m) => (m.capabilities || []).filter((c) => { const cap = contracts.capabilities.get(c); return cap && cap.status === 'active' && cap.visibility === 'public'; });
++
++/** The platform in numbers: counted from the pinned contracts and SDK, plus services up when Network answered. */
++function numbers({ docs, programmable, status }) {
++ const items = [
++ [programmable.length, 'services you can call', `/docs/services`],
++ [docs.capabilities.filter((c) => c.grantable && c.visibility === 'public').length, 'capabilities an app can request', '/docs/capabilities?grantable=1'],
++ [docs.events.length, 'event types to subscribe to', '/docs/events'],
++ [docs.sdk.length, 'SDK modules, typed', '/docs/sdk'],
++ status ? [status.up, `services up now, of ${status.checked} checked`, 'https://openvibe.network/status'] : null,
++ ].filter((x) => x && Number(x[0]) > 0);
++ return `<section class="sc-sec svc-num" aria-label="The platform in numbers"><ul>${items.map(([n, label, href]) => `<li><a href="${esc(href)}"><b>${esc(Number(n).toLocaleString('en-US'))}</b><span>${esc(label)}</span></a></li>`).join('')}</ul></section>`;
++}
++
++// What people build: one card per thing an app can do today, each pointing at code that does it. Text only, like
++// the services list: nine inline ring icons would add a quarter to the page's weight.
++const BUILD = [
++ { title: 'Sign people in with OpenVibe', text: 'OAuth 2.0 with PKCE: a public web app needs no secret, a server app verifies tokens offline. Example: oauth-app and browser-app.', href: example('oauth-app') },
++ { title: 'Store and serve files', text: 'Upload into your project\'s own Media space; sandbox files come back with signed, expiring links. Example: media-uploader.', href: example('media-uploader') },
++ { title: 'React to what happens', text: 'Publish to your project\'s topic, pull with a durable cursor, or take signed webhooks. Example: event-subscriber and webhook-consumer.', href: example('webhook-consumer') },
++ { title: 'Run tools as jobs', text: 'Submit an OpenVibe.Tools job once and follow its stream across restarts. Example: tool-job.', href: example('tool-job') },
++ { title: 'Build a chat bot', text: 'A bot that stays in its one room and always says it is a bot. Example: chat-bot.', href: example('chat-bot') },
++ { title: 'Search the network', text: 'Streams, recordings, pastes, posts, jobs and help answers from across the network in one query. Public results need no key.', href: '/docs/api/search' },
++ { title: 'Call many AI models', text: 'Chat, summaries, extraction and embeddings through one API; every run says which model answered and what it cost.', href: 'https://ai.openvibe.services/' },
++ { title: 'Keep objects in S3 buckets', text: 'Private storage zones that the AWS SDKs, rclone and backup tools reach over S3, with keys scoped to a zone.', href: 'https://openvibe.zone/' },
++ { title: 'Run jobs on a node', text: 'Send a job to one API; it runs on a node that offers its runtime, including your own computer, and streams its output.', href: 'https://openvibe.run/' },
++];
++
++function build() {
++ return showcase.features({
++ id: 'build', title: 'What you can build today',
++ lede: 'Each card is a working example in OpenVibe.Examples (MIT, with a smoke test) or a service you can call now.',
++ items: BUILD,
++ }).replace(/<\/section>$/, () => `<p class="sc-note">Start from one: <code>npx --package=https://codeload.github.com/OpenVibers/OpenVibe.Examples/tar.gz/refs/tags/v0.3.0 create-openvibe-app my-app --template web</code> (templates: web, server, bot, mod). <a href="${EXAMPLES}">All examples</a>.</p></section>`);
++}
++
++/** Quick start: code that runs against production with the pinned SDK (the first sample needs no key at all). */
++function quickStart({ sdkVersion }) {
++ const install = `npm i https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v${sdkVersion}`;
++ return showcase.code({
++ id: 'quick-start', title: 'Quick start',
++ lede: `openvibe-sdk v${sdkVersion}, for Node and the browser. Public reads need no key; an app's calls use the client id and secret of a sandbox app in your project.`,
++ samples: [
++ { label: 'No key: search the network and list its services', lang: 'js', code: `// ${install}
++const sdk = require('openvibe-sdk');
++const ov = sdk.createClient(); // public reads need no token
++
++const search = sdk.search.createSearchClient(ov);
++const { results } = await search.query('patch notes', { owner: 'blog' });
++
++const registry = sdk.registry.createRegistryClient(ov);
++const services = await registry.services(); // every service, with its health` },
++ { label: 'With an app: upload a file to your project', lang: 'js', code: `const sdk = require('openvibe-sdk');
++const tokens = sdk.auth.createServiceTokenClient({
++ clientId: process.env.OV_CLIENT_ID, clientSecret: process.env.OV_CLIENT_SECRET,
++});
++const ov = sdk.createClient({ tokenProvider: tokens });
++const { unverifiedClaims } = await tokens.getTokenInfo({ audience: 'openvibe.media' });
++const media = sdk.media.createMediaClient(ov, { app: unverifiedClaims.project_id });
++const file = await media.upload(bytes, { filename: 'logo.png', contentType: 'image/png' });` },
++ { label: 'Any language: an app token over HTTP', lang: 'bash', code: `curl -s https://openvibe.network/oauth/token \\
++ -d grant_type=client_credentials \\
++ -d client_id="$OV_CLIENT_ID" -d client_secret="$OV_CLIENT_SECRET" \\
++ -d audience=openvibe.media
++# → {"access_token":"…","token_type":"Bearer","expires_in":300,…}` },
++ ],
++ });
++}
++
++// The questions developers ask, answered from what the platform does today. The FAQPage JSON-LD repeats them.
++const FAQ = [
++ { q: 'What does it cost?', a: 'Nothing today. There is no plan to buy and no card to add; a project gets the limits on this page, which the services themselves enforce.' },
++ { q: 'Do I need permission to start?', a: 'No. Sign in, create a project and add a sandbox app; its sandbox capabilities are approved at once. Production is switched on by staff for a project that needs it.' },
++ { q: 'Which languages can I use?', a: 'Any that speaks HTTP: every API takes a Bearer token and answers JSON, with RFC 9457 problems for errors. openvibe-sdk adds typed clients for Node and the browser.' },
++ { q: 'Where do my client secrets live?', a: 'OpenVibe.Network shows a new secret once and keeps only a hash. This console passes it through in that one response and stores no copy.' },
++ { q: 'Can my users sign in with their OpenVibe account?', a: 'Yes. Authorization code with PKCE gives your app a token that acts for the person, carrying only the capabilities your app was granted. A public web app needs no secret.' },
++ { q: 'Is it open source?', a: 'Yes. Every OpenVibe service is open source on GitHub under OpenVibers, and the examples are MIT, so you can copy them into your own project.' },
++];
++
++function faq() {
++ return `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions developers ask</h2><div class="svc-faq">${FAQ.map((p) => `<div><h3>${esc(p.q)}</h3><p>${esc(p.a)}</p></div>`).join('')}</div></section>`;
++}
++
++/** "120 per_minute" → "120 a minute", bytes → KB/MB, a bare count → the number. */
++function limitAmount(v, unit) {
++ if (v === null || v === undefined) return 'unlimited';
++ if (v === 0) return 'none';
++ const n = Number(v);
++ const fmt = (x) => x.toLocaleString('en-US', { maximumFractionDigits: 1 });
++ switch (unit) {
++ case 'bytes': return n >= 1048576 ? `${fmt(n / 1048576)} MB` : n >= 1024 ? `${fmt(n / 1024)} KB` : `${fmt(n)} bytes`;
++ case 'count': return fmt(n);
++ case 'per_minute': return `${fmt(n)} a minute`;
++ case 'per_hour': return `${fmt(n)} an hour`;
++ case 'per_day': return `${fmt(n)} a day`;
++ case 'days': return `${fmt(n)} ${n === 1 ? 'day' : 'days'}`;
++ default: return unit ? `${fmt(n)} ${String(unit).replace(/_/g, ' ')}` : fmt(n);
++ }
++}
++
++module.exports = { createNetworkStatus, numbers, build, quickStart, faq, FAQ, BUILD, limitAmount, publicCaps };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 0db2fe4..3cf0cb4 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -15,6 +15,7 @@ const contracts = require('openvibe-contracts');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
++const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const path = require('path');
const express = require('express');
@@@ -25,43 +26,39 @@ const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { TABLES } = require('../db');
++const home = require('./home');
const { EVENT_TYPES } = require('../events/outbox');
const ROOT = path.join(__dirname, '..', '..');
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
--/** A limit as its service states it: a number with its unit, or unlimited/none. */
--function limitAmount(v, unit) {
-- if (v === null || v === undefined) return 'unlimited';
-- if (v === 0) return 'none';
-- return unit ? `${Number(v).toLocaleString('en-US')} ${unit}` : Number(v).toLocaleString('en-US');
--}
--
function createPageRoutes(ctx) {
const { config, docs, releases, trust, network, sso } = ctx;
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '32kb' });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ // Services up across the network, from Network's public status (read in the background, never awaited here).
++ const networkStatus = home.createNetworkStatus({ url: config.network.internalUrl, enabled: config.nodeEnv !== 'test' });
r.get('/', async (req, res) => {
const recent = await releases.recentPublic(10);
// The limits a developer's project really gets, from the service that enforces them (never restated here).
const eventsSource = ctx.limits && ctx.limits.bySource ? ctx.limits.bySource('events') : null;
const eventsLimits = eventsSource ? await ctx.limits.read(eventsSource) : null;
-- const limitRows = eventsLimits && eventsLimits.body ? eventsLimits.body.limits.slice(0, 6).map((l) => ({ label: l.label, values: [limitAmount(l.sandbox, l.unit), limitAmount(l.production, l.unit)] })) : [];
-- const snippet = `// openvibe-sdk v${docs.sdkVersion} (install: /docs/sdk)
--const { createClient } = require('openvibe-sdk');
--const { createServiceTokenClient } =
-- require('openvibe-sdk/auth');
++ const limitRows = eventsLimits && eventsLimits.body ? eventsLimits.body.limits.slice(0, 6).map((l) => ({ label: l.label, values: [home.limitAmount(l.sandbox, l.unit), home.limitAmount(l.production, l.unit)] })) : [];
++ // The hero's code runs as written against production with the pinned SDK: public reads need no key
++ // (test/home.test.js runs it against a mock).
++ const snippet = `// openvibe-sdk v${docs.sdkVersion}
++const sdk = require('openvibe-sdk');
++const ov = sdk.createClient(); // public reads need no key
++
++const search = sdk.search.createSearchClient(ov);
++const { results } = await search.query('go live');
--const ov = createClient({
-- tokenProvider: createServiceTokenClient({
-- clientId, clientSecret,
-- }),
--});
--const services = await ov.registry.services();`;
++const registry = sdk.registry.createRegistryClient(ov);
++const services = await registry.services();`;
// The services a project can call: every live service with a generated API reference and at least one
// capability an app can be granted (Services itself aside), with its tagline from its site block or its
// product manifest. Read from the pinned contracts, never typed here.
@@@ -73,7 +70,7 @@ const services = await ov.registry.services();`;
.sort((a, b) => ((a.site && a.site.position) ?? 50) - ((b.site && b.site.position) ?? 50) || a.name.localeCompare(b.name));
page(req, res, {
index: true, cache: PUBLIC_CACHE,
-- jsonLd: homeJsonLd(config),
++ jsonLd: [...homeJsonLd(config), seo.jsonLd.faq(home.FAQ)],
styles: [showcase.STYLESHEET],
body: html`${raw(showcase.hero({
eyebrow: 'OpenVibe.Services · the developer platform · alpha',
@@@ -84,6 +81,9 @@ const services = await ov.registry.services();`;
aside: { html: `<pre tabindex="0" class="services-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },
}))}
<div class="notice">Status: alpha. What works and what does not is listed on the <a href="/policy/transparency">transparency page</a>.</div>
++${raw(home.numbers({ docs, programmable, status: (ctx.networkStatus || networkStatus).peek() }))}
++${raw(home.build())}
++${raw(home.quickStart({ sdkVersion: docs.sdkVersion }))}
${raw(showcase.features({
title: 'Everything an integration needs', lede: 'Each piece is a public API with a reference generated from what production runs.',
items: [
@@@ -96,12 +96,15 @@ ${raw(showcase.features({
],
}))}
${programmable.length ? raw(showcase.features({
-- id: 'services', title: 'Services you can build on', lede: 'Each one runs in production today; its API reference is generated from the capabilities it declares.',
++ id: 'services', title: 'Services you can build on', lede: 'Each one runs in production today; its API reference is generated from the capabilities it declares, and the counts are the ones an app can be granted.',
// Text only: an inline icon per service would double the page's weight (the home page's budget).
-- items: programmable.map((m) => ({
-- title: m.name, text: (m.site && m.site.tagline) || productTagline.get(m.id) || '',
-- href: `/docs/api/${m.id}`,
-- })),
++ items: programmable.map((m) => {
++ const caps = home.publicCaps(m).length;
++ const events = (m.eventsProduced || []).length;
++ const counts = [`${caps} ${caps === 1 ? 'capability' : 'capabilities'}`, events ? `${events} event ${events === 1 ? 'type' : 'types'}` : ''].filter(Boolean).join(' · ');
++ const tagline = (m.site && m.site.tagline) || productTagline.get(m.id) || '';
++ return { title: m.name, text: tagline ? `${/[.!?]$/.test(tagline) ? tagline : `${tagline}.`} ${counts}` : counts, href: `/docs/api/${m.id}` };
++ }),
})) : ''}
${raw(showcase.steps({
title: 'From account to integration',
@@@ -115,7 +118,7 @@ ${raw(showcase.steps({
],
}))}
${raw(showcase.limits({
-- title: 'What a project gets', lede: 'Pricing is the limits: these are the numbers OpenVibe.Events enforces for every project today.',
++ title: 'What a project gets', lede: 'Projects cost nothing today: no plan to buy and no card to add. What a project gets is its limits, and these are the numbers OpenVibe.Events enforces for every project.',
columns: ['Sandbox', 'Production'], rows: limitRows, source: eventsSource && eventsSource.public,
}))}
<section class="sc-sec" aria-labelledby="h-ref"><h2 id="h-ref">Reference</h2>
@@@ -124,6 +127,7 @@ ${recent.length ? html`<section class="sc-sec" aria-labelledby="h-rel"><h2 id="h
${table(['App', 'Kind', 'Version', 'Status', 'Trust', 'Published'], recent.map((x) => [
html`<a href="/apps/${x.app_id}">${x.name}</a>`, x.kind, html`<a href="/releases/${x.id}">${x.version}</a>`, x.status, x.trust.tier, time(x.published_at),
]))}</section>` : ''}
++${raw(home.faq())}
${raw(showcase.cta({ title: 'Start building', text: 'Sign in with your OpenVibe account; your first project takes a minute. Coding with an agent? OpenVibe.Codes routes the task to Claude Code, Codex, OpenCode or your own model.', actions: [{ label: 'Create a project', href: '/projects', primary: true }, { label: 'Code with any agent', href: 'https://openvibe.codes' }] }))}`,
});
});
ddiff --git a/test/home.test.js b/test/home.test.js
nnew file mode 100644
iindex 0000000..fbec267
---- /dev/null
++++ b/test/home.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++/**
++ * The home page's own sections (server/http/home.js): numbers counted from the pinned contracts and SDK (services up
++ * only when Network's status was read), what you can build (each card points at a working example or a reference),
++ * quick-start code that runs against the pinned SDK as written, limits in plain units, and a FAQ that its FAQPage
++ * JSON-LD repeats word for word.
++ */
++const assert = require('assert');
++const sdk = require('openvibe-sdk');
++const home = require('../server/http/home');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ await check('limits read in plain units', async () => {
++ assert.strictEqual(home.limitAmount(120, 'per_minute'), '120 a minute');
++ assert.strictEqual(home.limitAmount(5242880, 'bytes'), '5 MB');
++ assert.strictEqual(home.limitAmount(65536, 'bytes'), '64 KB');
++ assert.strictEqual(home.limitAmount(20, 'count'), '20');
++ assert.strictEqual(home.limitAmount(1, 'days'), '1 day');
++ assert.strictEqual(home.limitAmount(0, 'days'), 'none');
++ assert.strictEqual(home.limitAmount(null, 'count'), 'unlimited');
++ });
++
++ await check('the quick-start code runs as written against the pinned SDK (a stub platform answers)', async () => {
++ const html = home.quickStart({ sdkVersion: '0.60.0' });
++ const unesc = (s) => s.replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
++ const samples = [...html.matchAll(/<code class="language-js">([\s\S]*?)<\/code>/g)].map((m) => unesc(m[1]));
++ assert.ok(samples.length >= 2, 'two Node samples');
++ const seen = [];
++ const fetchStub = async (url) => {
++ const u = new URL(String(url));
++ seen.push(u.pathname);
++ const body = u.pathname === '/.well-known/openvibe' ? { name: 'OpenVibe', issuer: 'https://openvibe.network', contracts: { version: require('openvibe-contracts/package.json').version }, services: [{ id: 'search', origin: 'https://search.openvibe.network' }, { id: 'network', origin: 'https://openvibe.network' }] }
++ : u.pathname === '/api/v1/search' ? { results: [{ title: 'Patch notes' }], next_cursor: null }
++ : u.pathname === '/api/v1/registry/services' ? { services: [{ id: 'live' }] }
++ : { ok: true };
++ return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
++ };
++ const realFetch = globalThis.fetch;
++ globalThis.fetch = fetchStub;
++ try {
++ // The first sample is the one that needs no key: run it whole.
++ const run = new Function('require', `return (async () => { ${samples[0]}\n return { results, services }; })();`);
++ const out = await run((id) => require(id));
++ assert.deepStrictEqual(out.results.map((r) => r.title), ['Patch notes']);
++ assert.deepStrictEqual(out.services.map((s) => s.id), ['live']);
++ assert.ok(seen.includes('/api/v1/search') && seen.includes('/api/v1/registry/services'), `it called search and the registry: ${seen}`);
++ } finally { globalThis.fetch = realFetch; }
++ // The app sample names only functions the pinned SDK has.
++ assert.strictEqual(typeof sdk.auth.createServiceTokenClient, 'function');
++ assert.strictEqual(typeof sdk.auth.createServiceTokenClient({ clientId: 'a', clientSecret: 'b' }).getTokenInfo, 'function');
++ assert.strictEqual(typeof sdk.media.createMediaClient(sdk.createClient(), { app: 'prj_1' }).upload, 'function');
++ assert.match(html, /codeload\.github\.com\/OpenVibers\/OpenVibe\.SDK\/tar\.gz\/refs\/tags\/v0\.60\.0/, 'the install line names the pinned tag');
++ assert.match(html, /https:\/\/openvibe\.network\/oauth\/token/, 'the curl sample uses Network\'s token endpoint');
++ });
++
++ const t = await boot();
++ try {
++ await check('the home page: hero code that runs, numbers, what you can build, quick start, FAQ and its JSON-LD', async () => {
++ let r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.ok(!/ov\.registry\./.test(r.text), 'the hero code no longer calls a client property the SDK does not have');
++ assert.match(r.text, /sdk\.registry\.createRegistryClient\(ov\)/);
++ assert.match(r.text, /<b>\d+<\/b><span>capabilities an app can request<\/span>/);
++ assert.match(r.text, /<b>\d+<\/b><span>event types to subscribe to<\/span>/);
++ assert.ok(!/services up now/.test(r.text), 'no services-up number while Network\'s status was not read');
++ for (const name of ['oauth-app', 'media-uploader', 'webhook-consumer', 'tool-job', 'chat-bot']) {
++ assert.ok(r.text.includes(`href="https://github.com/OpenVibers/OpenVibe.Examples/tree/main/examples/${name}"`), `links the ${name} example`);
++ }
++ assert.match(r.text, /id="quick-start"/);
++ assert.match(r.text, /\d+ capabilit(y|ies)( · \d+ event types?)?<\/small>/, 'each service says what an app can be granted');
++ assert.ok(!/per_minute|\d bytes</.test(r.text), 'limits read in plain units');
++ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const faq = blocks.find((b) => b['@type'] === 'FAQPage');
++ assert.ok(faq && faq.mainEntity.length === home.FAQ.length, 'a FAQPage node with every question');
++ faq.mainEntity.forEach((q) => assert.ok(r.text.includes(`<h3>${q.name.replace(/'/g, '&#39;')}</h3>`), `the page shows "${q.name}"`));
++ assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
++
++ t.ctx.networkStatus = { peek: () => ({ up: 37, checked: 38 }) };
++ r = await t.get('/');
++ assert.match(r.text, /<b>37<\/b><span>services up now, of 38 checked<\/span>/, 'services up once Network answered');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex 39ccb65..18cebcb 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -42,7 +42,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.services/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'FAQPage'], 'the home JSON-LD (the FAQPage repeats the questions the page shows)');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="services"', '/css/services.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
}
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 594e529..d45a0a9 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,13 +12,13 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 35, // 33.7 with openvibe-contracts 0.209 (2026-10-20: Blog, Reviews, Wiki, Coupons, Deals, Trade, Space, Host and News opened capabilities to apps, so the text-only catalog of services you can build on lists them); 30.8 on 2026-10-08 (six ring icons plus that catalog)
-- htmlBrotliKB: 8, // 7.4
++ htmlRawKB: 45, // 42.9 with the home's own sections (2026-10-22: the platform in numbers, nine text-only "what you can build" cards, three quick-start samples, six FAQs and their FAQPage JSON-LD); 33.7 with openvibe-contracts 0.209 (2026-10-20: Blog, Reviews, Wiki, Coupons, Deals, Trade, Space, Host and News opened capabilities to apps, so the text-only catalog of services you can build on lists them); 30.8 on 2026-10-08 (six ring icons plus that catalog)
++ htmlBrotliKB: 11, // 10.0 with those sections (2026-10-22); 7.4
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 1
-- cssRawKB: 19, // 17.3 (services.css + the cached /shared/showcase.css)
++ cssRawKB: 21, // 20.3 with the numbers and FAQ styles (2026-10-22); 17.3 (services.css + the cached /shared/showcase.css)
cssBrotliKB: 5, // 4.1
externalFiles: 2, // 0
};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex ad00eb6..1afb4c7 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -60,6 +60,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/limits.js': 'the services\' /limits.json (configured internal URLs)',
'server/domain/project-archive.js': 'no request: a shell snippet in the archive\'s README text',
'server/http/docs.js': 'Network changelog, Tools catalog and Billing policy (configured)',
++ 'server/http/home.js': 'Network\'s public /api/v1/status (configured OV_NETWORK internal URL) for the home page\'s services-up number; no token',
};
const root = path.join(__dirname, '..');
const found = [];