openvibers/openvibe.run Commits
Run: openvibe.run gets its own public site (front page, legal, robots, sitemap) (#28)
Changes
@@@ -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@@@ -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;@@@ -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');nnew file mode 100644@@@ -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, '&').replace(/</g, '<')}</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, '&').replace(/</g, '<')}</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 };nnew file mode 100644@@@ -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('"artifact"') || 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();++})();
@@ -40,6 +40,9 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
@@ -40,6 +40,9 @@ placed from the Fabric's offers and actually runs — the poller in `server/jobs
## Owns
## 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
- 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.
(`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
- The project scope: a job's `project_id` and `requester` come from the caller's Network token
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
## /api/v1/jobs/{id}/stream the job's Server-Sent Events (long-lived: never buffered or cached,
## /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)
## Last-Event-ID forwarded so a reconnect resumes where it left)
## /api/health, /api/ready, /release.json
## /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)
## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
## /internal/ host-local service endpoints, refused here
## /internal/ host-local service endpoints, refused here
##
##
@@ -23,6 +24,7 @@
@@ -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_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_stream:10m rate=2r/s;
+limit_req_zone $binary_remote_addr zone=ovrun_pages:10m rate=10r/s;
# ── HTTP → HTTPS ────────────────────────────────────────────
# ── HTTP → HTTPS ────────────────────────────────────────────
server {
server {
@@ -121,8 +123,13 @@ server {
@@ -121,8 +123,13 @@ server {
proxy_pass http://127.0.0.1:4920;
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) {
location ~ /\.(?!well-known) {
deny all;
deny all;
@@ -94,6 +94,22 @@ function createApp(opts = {}) {
@@ -94,6 +94,22 @@ function createApp(opts = {}) {
apiAuth.middleware,
apiAuth.middleware,
v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now, log }));
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) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
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');
return res.status(404).type('text/plain').send('Not found\n');
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -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, '&').replace(/</g, '<')}</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, '&').replace(/</g, '<')}</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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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('"artifact"') || 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();
+})();