openvibers/openvibe.run Commits

Run: openvibe.run gets its own public site (front page, legal, robots, sitemap) (#28)

OP OpenVibers committed 11 Oct 2026 · b453c7265e · 5 files, +175 −2

openvibe.run was a parked domain (a 302 to openvibe.network) although Run is a working developer product: a project submits a job, Run places it on a node from the Fabric's offers, Bot runs and meters it per second, and the output streams back. Run now serves its own public site.

- **`server/web/home.js`:** the front page in the OpenVibe Frame, with what a job is and how it runs (submit, place, run, end), what you get, and the API in brief. The example request follows `run.job-create-request@1` (a pre-registered artifact, input, the three limits, ttl), and the route names are the router's own (`/stream/ticket`). It is `Cache-Control: public, max-age=300`, under its own CSP (the Frame's allowances, `frame-ancestors 'none'`).
- **`server/app.js`:** `/shared` (the Frame's assets), the legal pages (`openvibe-shared/legal`, hosting profile), `robots.txt` (the API disallowed), `sitemap.xml`, `favicon.ico` and `manifest.webmanifest`. The API's unknown paths stay RFC 9457 404s.
- **`deploy/nginx/openvibe.run.conf`:** `/` goes to the app under a page rate limit (`ovrun_pages`, 10 r/s, burst 20) instead of a 404.

After the deploy, the vhost is installed and OpenVibe.Network's `parked-domains.conf` drops openvibe.run (a separate PR).

**Test:** new `test/site.test.js` (the page, its CSP and cache, no placeholder copy, the contract's example, the legal pages, robots, sitemap, icon, manifest, and the API 404 as a problem). Covering tests pass.

Changes

ddiff --git a/README.md b/README.md
iindex d6c001d..9e5eb6b 100644
---- a/README.md
++++ b/README.md
@@@ -40,6 +40,9 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
## Owns
++- The public site at openvibe.run (`server/web/home.js`, `server/app.js`): the front page in the OpenVibe Frame (what a
++ job is and how it runs, the API in brief), the legal pages (`openvibe-shared/legal`), `robots.txt`, the sitemap,
++ the icon and the web manifest. It says only what works today; `test/site.test.js` checks it.
- The job API (`server/api/v1.js`, one capability per route — the table below) and the job's lifecycle record
(`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock.
- The project scope: a job's `project_id` and `requester` come from the caller's Network token
ddiff --git a/deploy/nginx/openvibe.run.conf b/deploy/nginx/openvibe.run.conf
iindex 558fa52..ff00f73 100644
---- a/deploy/nginx/openvibe.run.conf
++++ b/deploy/nginx/openvibe.run.conf
@@@ -9,6 +9,7 @@
## /api/v1/jobs/{id}/stream the job's Server-Sent Events (long-lived: never buffered or cached,
## Last-Event-ID forwarded so a reconnect resumes where it left)
## /api/health, /api/ready, /release.json
++## /, /terms, /privacy, /dmca, /robots.txt, /sitemap.xml, /favicon.ico, /manifest.webmanifest, /shared/… the public site
## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
## /internal/ host-local service endpoints, refused here
##
@@@ -23,6 +24,7 @@
limit_req_zone $binary_remote_addr zone=ovrun_api:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=ovrun_stream:10m rate=2r/s;
++limit_req_zone $binary_remote_addr zone=ovrun_pages:10m rate=10r/s;
# ── HTTP → HTTPS ────────────────────────────────────────────
server {
@@@ -121,8 +123,13 @@ server {
proxy_pass http://127.0.0.1:4920;
}
-- # ── Nothing else is served ──────────────────────────────
-- location / { return 404; }
++ # ── The public site: the front page, the legal pages, robots.txt, the sitemap, the icon, the manifest and the
++ # Frame's /shared assets (server/web/home.js); anything else is the app's own 404 ──
++ location / {
++ limit_req zone=ovrun_pages burst=20 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4920;
++ }
location ~ /\.(?!well-known) {
deny all;
ddiff --git a/server/app.js b/server/app.js
iindex 184f972..8a5c204 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -94,6 +94,22 @@ function createApp(opts = {}) {
apiAuth.middleware,
v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now, log }));
++ // ── The public site (openvibe.run): the front page, the legal pages and the Frame's shared assets. ──
++ const home = require('./web/home');
++ const appIcon = require('openvibe-shared/app-icon');
++ app.use('/shared', require('openvibe-shared/serve').handler());
++ app.get('/', (req, res) => {
++ res.setHeader('Content-Security-Policy', home.HOME_CSP);
++ res.setHeader('Cache-Control', 'public, max-age=300');
++ res.type('html').send(home.renderHome({ config }));
++ });
++ { const legal = require('openvibe-shared/legal');
++ app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', home.HOME_CSP); next(); }, legal.handler({ id: 'run', service: 'run', host: 'openvibe.run', name: home.SITE_NAME, profile: 'hosting' })); }
++ app.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /api/', 'Disallow: /internal/', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
++ app.get('/sitemap.xml', (req, res) => res.type('application/xml').send(require('openvibe-shared/seo').sitemapXml(['/', '/terms', '/privacy', '/dmca'].map((p) => ({ loc: `${config.baseUrl}${p}` })))));
++ app.get('/favicon.ico', (req, res) => res.type('image/svg+xml').set('Cache-Control', 'public, max-age=86400').send(appIcon.favicon({ site: 'run' })));
++ app.get('/manifest.webmanifest', (req, res) => res.type('application/manifest+json').send(JSON.stringify(appIcon.manifest({ site: 'run', name: home.SITE_NAME, shortName: 'Run', description: home.DESCRIPTION, startUrl: '/' }))));
++
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
return res.status(404).type('text/plain').send('Not found\n');
ddiff --git a/server/web/home.js b/server/web/home.js
nnew file mode 100644
iindex 0000000..d5bbf3c
---- /dev/null
++++ b/server/web/home.js
@@@ -0,0 +1,104 @@
++'use strict';
++
++/**
++ * The front page of openvibe.run (GET /): the OpenVibe Frame (openvibe-shared/shell: navbar, footer, theme) around
++ * openvibe-shared/showcase sections. The copy says only what works today (README "How a job runs"): submit a job to
++ * the API with a project's token, Run places it from the Fabric's node offers, OpenVibe.Bot sends it to the node and
++ * meters it per second, the output streams back, and the job ends in one terminal state.
++ *
++ * renderHome({ config }) the whole document
++ * HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
++ */
++const shell = require('openvibe-shared/shell');
++const showcase = require('openvibe-shared/showcase');
++const appIcon = require('openvibe-shared/app-icon');
++
++const SITE_NAME = 'OpenVibe.Run';
++const NETWORK_URL = 'https://openvibe.network';
++const DESCRIPTION = 'Run a job on a node that can: submit it to one API with your project\'s token, and Run places it on a node '
++ + 'that offers its runtime, streams its output and meters it per second.';
++
++const HOME_CSP = [
++ "default-src 'self'",
++ // The Frame's theme-loader, navbar and footer are served from /shared; the inline boot is the shell's.
++ "script-src 'self' 'unsafe-inline' https://openvibe.network",
++ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",
++ "font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
++ "img-src 'self' data: https:",
++ // The navbar asks the Network for the session, menus and notifications.
++ "connect-src 'self' https://openvibe.network https://openvibe.events",
++ "frame-src 'self' https://openvibe.network",
++ "frame-ancestors 'none'",
++ "object-src 'none'",
++ "base-uri 'self'",
++ "form-action 'self' https://openvibe.network",
++].join('; ');
++
++const code = (s) => `<code>${s.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</code>`;
++
++const SUBMIT_EXAMPLE = `curl -X POST https://openvibe.run/api/v1/jobs \\
++ -H "Authorization: Bearer $PROJECT_TOKEN" -H "Content-Type: application/json" \\
++ -d '{"class":"function","artifact":{"name":"thumbnail","version":"1.2.0"},"args":{"width":320},
++ "limits":{"wall_ms":30000,"cpu_ms":30000,"mem_bytes":268435456},"ttl_ms":600000}'`;
++
++function renderHome({ config }) {
++ const nav = {
++ service: 'run',
++ apiBase: NETWORK_URL,
++ links: [{ label: 'How it works', href: '#how' }, { label: 'API', href: '#api' }],
++ history: { type: 'page', title: SITE_NAME },
++ };
++ return shell.page({
++ name: SITE_NAME, service: 'run', lang: 'en',
++ title: `${SITE_NAME}: run a job on a node that can`,
++ siteName: SITE_NAME, description: DESCRIPTION, summary: DESCRIPTION,
++ canonical: `${config.baseUrl}/`, robots: 'index, follow',
++ navbar: nav, footer: { service: 'run', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME }, home: '/',
++ navLinks: nav.links,
++ head: [appIcon.headTags({ site: 'run' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="stylesheet" href="${showcase.STYLESHEET}">`].join('\n'),
++ body: `<div id="navbar-mount"></div>\n<main id="main">${body()}</main>`,
++ });
++}
++
++function body() {
++ return showcase.hero({
++ eyebrow: 'OpenVibe.Run · jobs on nodes · alpha',
++ title: 'Run a job.',
++ accent: 'On a node that can.',
++ lede: 'Send a job to one API with your project\'s token. Run picks a node that offers the runtime the job asks for, '
++ + 'hands it over, streams its output back to you and settles it, metered per second it runs.',
++ actions: [{ label: 'How it works', href: '#how', primary: true }, { label: 'The API', href: '#api' }],
++ note: 'Alpha. A job runs on nodes paired with OpenVibe (the OpenVibe Node); with none online for its class it waits, then expires.',
++ }) + showcase.steps({
++ id: 'how',
++ title: 'How a job runs',
++ items: [
++ { title: 'Submit', text: 'POST the job to /api/v1/jobs with a project token holding run.job.submit: which artifact to run, its input and its limits. Run checks it against the job contract, stores it queued and answers at once.' },
++ { title: 'Place', text: 'Run reads the network\'s node offers for the job\'s class and chooses one its requirements allow. With no eligible node it waits, retrying every second, until its time to live runs out.' },
++ { title: 'Run', text: 'OpenVibe.Bot sends the job to the node over its device link and meters every wall-clock second it runs. The node\'s output comes back as it is written.' },
++ { title: 'End', text: 'Succeeded, failed, cancelled or expired. An end state never changes, and the job, its timings, its exit and its metered seconds stay readable to the project.' },
++ ],
++ }) + showcase.features({
++ title: 'What you get',
++ items: [
++ { icon: 'ov:code', title: 'One API', text: 'Submit, read, list, cancel and stream jobs over HTTP, with RFC 9457 problems for every refusal.' },
++ { icon: 'ov:account', title: 'Your project\'s jobs only', text: 'A job belongs to a project and is visible only to that project\'s token: never to another project, never by guessing an id.' },
++ { icon: 'ov:live', title: 'Live output', text: 'A short-lived ticket opens the job\'s event stream: state changes and the node\'s stdout as they happen.' },
++ { icon: 'ov:check', title: 'Placed, not guessed', text: 'Each placement records the node, the provider and the region it chose, from the offers its requirements allow.' },
++ { icon: 'ov:history', title: 'Metered per second', text: 'The node link measures each job\'s wall clock; the reading is what the job is charged against, never an estimate.' },
++ { icon: 'ov:error', title: 'Ends cleanly', text: 'Cancel a job, or let its time to live expire it. A node that disappears mid-run fails the job instead of leaving it running forever.' },
++ ],
++ }) + `<section class="sc-sec" id="api"><h2>The API</h2>
++<p class="sc-lede">Jobs are created with a project token from ${'<a href="https://openvibe.services">OpenVibe.Services</a>'} (capability ${code('run.job.submit')}; reading, listing, cancelling and streaming take ${code('run.job.read')}, ${code('run.job.list')}, ${code('run.job.cancel')} and ${code('run.job.stream')}). A job names its runtime class, a pre-registered artifact (a name and one exact version), its input and its limits (wall clock, CPU time, memory):</p>
++<pre class="sc-code">${SUBMIT_EXAMPLE.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</pre>
++<ul class="facts">
++<li>${code('GET /api/v1/jobs/:id')}: state, node, timings, exit and metered seconds.</li>
++<li>${code('GET /api/v1/jobs')}: the project's jobs, newest first.</li>
++<li>${code('POST /api/v1/jobs/:id/cancel')}: stops it; an ended job stays ended.</li>
++<li>${code('POST /api/v1/jobs/:id/stream/ticket')} then ${code('GET /api/v1/jobs/:id/stream?ticket=…')}: its events and output as server-sent events (a service may send its own token instead).</li>
++</ul>
++<p>To run jobs on your own machine, pair it with the <a href="https://github.com/OpenVibers/OpenVibe.Node">OpenVibe Node</a>; it offers the runtimes it can run. The source is <a href="https://github.com/OpenVibers/OpenVibe.Run">OpenVibers/OpenVibe.Run</a> (AGPL-3.0).</p>
++</section>`;
++}
++
++module.exports = { renderHome, HOME_CSP, SITE_NAME, DESCRIPTION };
ddiff --git a/test/site.test.js b/test/site.test.js
nnew file mode 100644
iindex 0000000..3d98b8a
---- /dev/null
++++ b/test/site.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++/**
++ * The public site of openvibe.run: the front page in the OpenVibe Frame (its own CSP, cached briefly), the legal
++ * pages, robots.txt, the sitemap, the icon and the manifest; the API's 404 stays an RFC 9457 problem.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('GET / is the front page in the Frame, with its CSP, and says only what works', async () => {
++ const r = await t.call('GET', '/', { token: null, headers: { Accept: 'text/html' } });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /text\/html/);
++ assert.match(r.text, /<title>OpenVibe\.Run: run a job on a node that can/);
++ assert.match(r.text, /id="navbar-mount"/);
++ assert.match(r.headers.get('content-security-policy'), /frame-ancestors 'none'/);
++ assert.match(r.headers.get('cache-control'), /max-age=300/);
++ assert.ok(r.text.includes('POST /api/v1/jobs/:id/stream/ticket'), 'the stream ticket route as the API names it');
++ assert.ok(r.text.includes('&quot;artifact&quot;') || r.text.includes('"artifact"'), 'the example names an artifact, as the job contract requires');
++ assert.ok(!/coming soon|placeholder/i.test(r.text), 'no placeholder copy');
++ });
++ await check('the legal pages, robots.txt, the sitemap, the icon and the manifest', async () => {
++ for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.call('GET', p, { token: null })).status, 200, p);
++ const robots = await t.call('GET', '/robots.txt', { token: null });
++ assert.match(robots.text, /Disallow: \/api\//);
++ assert.match(robots.text, /Sitemap: .*\/sitemap\.xml/);
++ assert.match((await t.call('GET', '/sitemap.xml', { token: null })).text, /<loc>[^<]*\/terms<\/loc>/);
++ assert.match((await t.call('GET', '/favicon.ico', { token: null })).headers.get('content-type'), /image\/svg\+xml/);
++ const manifest = JSON.parse((await t.call('GET', '/manifest.webmanifest', { token: null })).text);
++ assert.strictEqual(manifest.name, 'OpenVibe.Run');
++ });
++ await check('an unknown API path is still a problem+json 404', async () => {
++ const r = await t.call('GET', '/api/v1/nothing-here', { token: null });
++ assert.strictEqual(r.status, 404);
++ assert.match(r.headers.get('content-type'), /problem\+json/);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();