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)
Changes
@@@ -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); }nnew file mode 100644@@@ -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 };@@@ -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' }] }))}`,});});nnew file mode 100644@@@ -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(/'/g, "'").replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/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, ''')}</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); });@@@ -42,7 +42,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiassert.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}`);}@@@ -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.4jsFiles: 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.1externalFiles: 2, // 0};@@@ -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 = [];
@@ -79,3 +79,17 @@ meter.headroom { width: 8rem; max-width: 100%; vertical-align: middle; }
@@ -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-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
.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; }
.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); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -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 };
@@ -15,6 +15,7 @@ const contracts = require('openvibe-contracts');
@@ -15,6 +15,7 @@ const contracts = require('openvibe-contracts');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
@@ -25,43 +26,39 @@ const { send } = require('../render/layout');
@@ -25,43 +26,39 @@ const { send } = require('../render/layout');
const { markdown } = require('../render/markdown');
const { markdown } = require('../render/markdown');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { TABLES } = require('../db');
const { TABLES } = require('../db');
+const home = require('./home');
const { EVENT_TYPES } = require('../events/outbox');
const { EVENT_TYPES } = require('../events/outbox');
const ROOT = path.join(__dirname, '..', '..');
const ROOT = path.join(__dirname, '..', '..');
const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
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) {
function createPageRoutes(ctx) {
const { config, docs, releases, trust, network, sso } = ctx;
const { config, docs, releases, trust, network, sso } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
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 });
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) => {
r.get('/', async (req, res) => {
const recent = await releases.recentPublic(10);
const recent = await releases.recentPublic(10);
// The limits a developer's project really gets, from the service that enforces them (never restated here).
// 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 eventsSource = ctx.limits && ctx.limits.bySource ? ctx.limits.bySource('events') : null;
const eventsLimits = eventsSource ? await ctx.limits.read(eventsSource) : 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
// 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
// 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.
// product manifest. Read from the pinned contracts, never typed here.
@@ -73,7 +70,7 @@ const services = await ov.registry.services();`;
@@ -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));
.sort((a, b) => ((a.site && a.site.position) ?? 50) - ((b.site && b.site.position) ?? 50) || a.name.localeCompare(b.name));
page(req, res, {
page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
- jsonLd: homeJsonLd(config),
+ jsonLd: [...homeJsonLd(config), seo.jsonLd.faq(home.FAQ)],
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body: html`${raw(showcase.hero({
body: html`${raw(showcase.hero({
eyebrow: 'OpenVibe.Services · the developer platform · alpha',
eyebrow: 'OpenVibe.Services · the developer platform · alpha',
@@ -84,6 +81,9 @@ const services = await ov.registry.services();`;
@@ -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>` },
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>
<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({
${raw(showcase.features({
title: 'Everything an integration needs', lede: 'Each piece is a public API with a reference generated from what production runs.',
title: 'Everything an integration needs', lede: 'Each piece is a public API with a reference generated from what production runs.',
items: [
items: [
@@ -96,12 +96,15 @@ ${raw(showcase.features({
@@ -96,12 +96,15 @@ ${raw(showcase.features({
],
],
}))}
}))}
${programmable.length ? 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).
// 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({
${raw(showcase.steps({
title: 'From account to integration',
title: 'From account to integration',
@@ -115,7 +118,7 @@ ${raw(showcase.steps({
@@ -115,7 +118,7 @@ ${raw(showcase.steps({
],
],
}))}
}))}
${raw(showcase.limits({
${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,
columns: ['Sandbox', 'Production'], rows: limitRows, source: eventsSource && eventsSource.public,
}))}
}))}
<section class="sc-sec" aria-labelledby="h-ref"><h2 id="h-ref">Reference</h2>
<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
@@ -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) => [
${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),
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>` : ''}
]))}</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' }] }))}`,
${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' }] }))}`,
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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(/'/g, "'").replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/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, ''')}</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); });
@@ -42,7 +42,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -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('<link rel="canonical" href="https://openvibe.services/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
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']);
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"']) {
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}`);
assert.ok(head.includes(tag), `head has ${tag}`);
}
}
@@ -12,13 +12,13 @@ const { spawn } = require('child_process');
@@ -12,13 +12,13 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
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)
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)
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)
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
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
cssBrotliKB: 5, // 4.1
externalFiles: 2, // 0
externalFiles: 2, // 0
};
};
@@ -60,6 +60,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -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/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/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/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 root = path.join(__dirname, '..');
const found = [];
const found = [];