openvibers/openvibe.report Commits
Host Bench-backed Exchange Arena on Report (#142)
Changes
nnew file mode 100644@@@ -0,0 +1,11 @@++/* The Arena on Report (/ai/arena): Exchange's round fragments, side by side on a computer, stacked on a phone. */++.ex-wrap { width: min(1100px, calc(100% - 32px)); margin: 32px auto; }++.ex-arena-grid { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }++.ex-arena-grid > * { min-width: 0; overflow-wrap: anywhere; }++.ex-arena-form textarea { width: 100%; min-height: 140px; }++.ex-wrap a, .ex-wrap button, .ex-wrap summary { min-height: 44px; display: inline-flex; align-items: center; }++.ex-arena-answer { white-space: pre-wrap; }++.ex-wrap pre { overflow: auto; max-width: 100%; }++@media (min-width: 900px) {++ .ex-arena-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }++}@@@ -328,6 +328,7 @@ async function createApp(opts = {}) {// mounts after the page routes, which keep their own cache policy and answer the HTML (with the discovery links),// and before the 404, so an unknown twin is still a 404. A .md path skips the page routes (http/pages.js).const publicPages = createPublicPages(ctx);++ app.use(require('./http/arena').createArenaRoutes(ctx));app.use(createPageRoutes({ ...ctx, publicPages }));require('openvibe-shared/agent-layer').mount(app, {descriptor: require('./agent-descriptor'),nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * The Arena on Report (/ai/arena, beside the rankings it feeds; plan "Exchange is the API platform"). Exchange keeps the++ * Arena's data, private-round access and Bench vote rules; Report shows its rounds and passes asks and votes through:++ *++ * GET /ai/arena, /ai/arena/:id → Exchange GET /api/v1/arena/rounds[/:id] (an escaped HTML fragment)++ * POST /ai/arena → Exchange POST /api/v1/arena/rounds (a new round → 303 to it)++ * POST /ai/arena/:id/vote → Exchange POST /api/v1/arena/rounds/:id/votes++ *++ * A POST needs a signed-in person (else to sign-in) and this site's Origin (else 403). The person's token goes along++ * so Exchange applies their access; links in the fragment that pointed at Exchange's old /arena paths point here.++ */++const express = require('express');++const views = require('../views');++++const EXCHANGE_ARENA = 'https://openvibe.exchange/api/v1/arena';++const LOCATION = /^\/ai\/arena(?:\/[a-zA-Z0-9_-]+)?$/;++++/** Exchange's fragment, with its links moved to Report's paths. */++function relink(html) {++ return html++ .replace(/(href|action)="\/arena\/ask"/g, '$1="/ai/arena"')++ .replace(/(href|action)="\/arena/g, '$1="/ai/arena')++ .replace(/href="\/rankings/g, 'href="/ai')++ .replace(/next=%2Farena/g, 'next=%2Fai%2Farena');++}++++function createArenaRoutes({ config, fetchImpl = globalThis.fetch }) {++ const r = express.Router();++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));++ const page = (req, res, status, body, robots = 'noindex, nofollow') =>++ res.status(status).type('html').send(views.layout({ title: 'The Arena', path: req.path, active: '/ai', robots, body }));++++ r.all(['/ai/arena', '/ai/arena/:id', '/ai/arena/:id/vote'], async (req, res, next) => {++ if (!['GET', 'POST'].includes(req.method)) return next();++ res.set('Cache-Control', 'no-store');++ if (req.method === 'POST' && (!req.user || !req.token)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ if (req.method === 'POST' && req.get('origin') !== new URL(config.baseUrl).origin) return res.sendStatus(403);++ let path = '/rounds';++ if (req.params.id) path += `/${encodeURIComponent(req.params.id)}`;++ if (req.path.endsWith('/vote')) path += '/votes';++ try {++ const upstream = await fetchImpl(EXCHANGE_ARENA + path, {++ method: req.method,++ headers: { ...(req.token ? { authorization: `Bearer ${req.token}` } : {}), 'content-type': 'application/json' },++ ...(req.method === 'POST' ? { body: JSON.stringify(req.body) } : {}),++ redirect: 'error',++ signal: AbortSignal.timeout(120000),++ });++ const data = await upstream.json();++ if (data.location && LOCATION.test(data.location)) return res.redirect(303, data.location);++ // Exchange renders the fragment escaped, with its own private-round access and Bench vote rules.++ const body = typeof data.html === 'string' ? relink(data.html) : '<p>The Arena is unavailable. Try again shortly.</p>';++ return page(req, res, upstream.status, `<link rel="stylesheet" href="/css/arena.css">${body}`);++ } catch {++ return page(req, res, 502, '<p>The Arena could not answer. Please try again.</p>', undefined);++ }++ });++ return r;++}++++module.exports = { createArenaRoutes, relink };nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++// The Arena on Report: anyone reads it, a POST needs a signed-in person and this site's Origin, asks and votes pass++// through to Exchange's Arena API with the person's token, and Exchange's /arena links point at /ai/arena.++const assert = require('node:assert/strict');++const express = require('express');++const { createArenaRoutes } = require('../server/http/arena');++++(async () => {++ const app = express();++ const calls = [];++ app.use((req, res, next) => {++ if (req.get('x-test-user')) { req.user = { id: 'test' }; req.token = 'test-token'; }++ next();++ });++ app.use(createArenaRoutes({++ config: { baseUrl: 'https://openvibe.report' },++ fetchImpl: async (url, opts) => {++ calls.push({ url, opts });++ const body = opts.method === 'POST' ? { location: '/ai/arena/round1' } : { html: '<form action="/arena/ask"><button>Ask</button></form>' };++ return new Response(JSON.stringify(body), { status: 200 });++ },++ }));++ const server = app.listen(0, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ try {++ const anon = await fetch(`${base}/ai/arena`, { redirect: 'manual' });++ assert.equal(anon.status, 200, 'anyone reads the Arena');++ const page = await fetch(`${base}/ai/arena`, { headers: { 'x-test-user': '1' } });++ assert.ok((await page.text()).includes('action="/ai/arena"'), "Exchange's /arena links point here");++ const bad = await fetch(`${base}/ai/arena`, { method: 'POST', headers: { 'x-test-user': '1', origin: 'https://evil.example' } });++ assert.equal(bad.status, 403, 'another Origin may not post');++ const signedOut = await fetch(`${base}/ai/arena`, { method: 'POST', redirect: 'manual', headers: { origin: 'https://openvibe.report' } });++ assert.equal(signedOut.status, 303, 'a signed-out POST goes to sign-in');++ assert.match(signedOut.headers.get('location'), /^\/auth\/login\?next=/);++ const vote = await fetch(`${base}/ai/arena/round1/vote`, {++ method: 'POST', redirect: 'manual',++ headers: { 'x-test-user': '1', origin: 'https://openvibe.report', 'content-type': 'application/x-www-form-urlencoded' },++ body: 'outcome=left',++ });++ assert.equal(vote.status, 303);++ assert.ok(calls.at(-1).url.endsWith('/rounds/round1/votes'));++ assert.equal(JSON.parse(calls.at(-1).opts.body).outcome, 'left');++ assert.equal(calls.at(-1).opts.headers.authorization, 'Bearer test-token', "the person's own token");++ console.log('Report Arena: sign-in, Origin, the API pass-through and redirects passed');++ } finally {++ await new Promise((r) => server.close(r));++ }++})().catch((e) => { console.error(e); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+/* The Arena on Report (/ai/arena): Exchange's round fragments, side by side on a computer, stacked on a phone. */
+.ex-wrap { width: min(1100px, calc(100% - 32px)); margin: 32px auto; }
+.ex-arena-grid { display: grid; gap: 16px; grid-template-columns: minmax(0, 1fr); }
+.ex-arena-grid > * { min-width: 0; overflow-wrap: anywhere; }
+.ex-arena-form textarea { width: 100%; min-height: 140px; }
+.ex-wrap a, .ex-wrap button, .ex-wrap summary { min-height: 44px; display: inline-flex; align-items: center; }
+.ex-arena-answer { white-space: pre-wrap; }
+.ex-wrap pre { overflow: auto; max-width: 100%; }
+@media (min-width: 900px) {
+ .ex-arena-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
@@ -328,6 +328,7 @@ async function createApp(opts = {}) {
@@ -328,6 +328,7 @@ async function createApp(opts = {}) {
// mounts after the page routes, which keep their own cache policy and answer the HTML (with the discovery links),
// mounts after the page routes, which keep their own cache policy and answer the HTML (with the discovery links),
// and before the 404, so an unknown twin is still a 404. A .md path skips the page routes (http/pages.js).
// and before the 404, so an unknown twin is still a 404. A .md path skips the page routes (http/pages.js).
const publicPages = createPublicPages(ctx);
const publicPages = createPublicPages(ctx);
+ app.use(require('./http/arena').createArenaRoutes(ctx));
app.use(createPageRoutes({ ...ctx, publicPages }));
app.use(createPageRoutes({ ...ctx, publicPages }));
require('openvibe-shared/agent-layer').mount(app, {
require('openvibe-shared/agent-layer').mount(app, {
descriptor: require('./agent-descriptor'),
descriptor: require('./agent-descriptor'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * The Arena on Report (/ai/arena, beside the rankings it feeds; plan "Exchange is the API platform"). Exchange keeps the
+ * Arena's data, private-round access and Bench vote rules; Report shows its rounds and passes asks and votes through:
+ *
+ * GET /ai/arena, /ai/arena/:id → Exchange GET /api/v1/arena/rounds[/:id] (an escaped HTML fragment)
+ * POST /ai/arena → Exchange POST /api/v1/arena/rounds (a new round → 303 to it)
+ * POST /ai/arena/:id/vote → Exchange POST /api/v1/arena/rounds/:id/votes
+ *
+ * A POST needs a signed-in person (else to sign-in) and this site's Origin (else 403). The person's token goes along
+ * so Exchange applies their access; links in the fragment that pointed at Exchange's old /arena paths point here.
+ */
+const express = require('express');
+const views = require('../views');
+
+const EXCHANGE_ARENA = 'https://openvibe.exchange/api/v1/arena';
+const LOCATION = /^\/ai\/arena(?:\/[a-zA-Z0-9_-]+)?$/;
+
+/** Exchange's fragment, with its links moved to Report's paths. */
+function relink(html) {
+ return html
+ .replace(/(href|action)="\/arena\/ask"/g, '$1="/ai/arena"')
+ .replace(/(href|action)="\/arena/g, '$1="/ai/arena')
+ .replace(/href="\/rankings/g, 'href="/ai')
+ .replace(/next=%2Farena/g, 'next=%2Fai%2Farena');
+}
+
+function createArenaRoutes({ config, fetchImpl = globalThis.fetch }) {
+ const r = express.Router();
+ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
+ const page = (req, res, status, body, robots = 'noindex, nofollow') =>
+ res.status(status).type('html').send(views.layout({ title: 'The Arena', path: req.path, active: '/ai', robots, body }));
+
+ r.all(['/ai/arena', '/ai/arena/:id', '/ai/arena/:id/vote'], async (req, res, next) => {
+ if (!['GET', 'POST'].includes(req.method)) return next();
+ res.set('Cache-Control', 'no-store');
+ if (req.method === 'POST' && (!req.user || !req.token)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ if (req.method === 'POST' && req.get('origin') !== new URL(config.baseUrl).origin) return res.sendStatus(403);
+ let path = '/rounds';
+ if (req.params.id) path += `/${encodeURIComponent(req.params.id)}`;
+ if (req.path.endsWith('/vote')) path += '/votes';
+ try {
+ const upstream = await fetchImpl(EXCHANGE_ARENA + path, {
+ method: req.method,
+ headers: { ...(req.token ? { authorization: `Bearer ${req.token}` } : {}), 'content-type': 'application/json' },
+ ...(req.method === 'POST' ? { body: JSON.stringify(req.body) } : {}),
+ redirect: 'error',
+ signal: AbortSignal.timeout(120000),
+ });
+ const data = await upstream.json();
+ if (data.location && LOCATION.test(data.location)) return res.redirect(303, data.location);
+ // Exchange renders the fragment escaped, with its own private-round access and Bench vote rules.
+ const body = typeof data.html === 'string' ? relink(data.html) : '<p>The Arena is unavailable. Try again shortly.</p>';
+ return page(req, res, upstream.status, `<link rel="stylesheet" href="/css/arena.css">${body}`);
+ } catch {
+ return page(req, res, 502, '<p>The Arena could not answer. Please try again.</p>', undefined);
+ }
+ });
+ return r;
+}
+
+module.exports = { createArenaRoutes, relink };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+// The Arena on Report: anyone reads it, a POST needs a signed-in person and this site's Origin, asks and votes pass
+// through to Exchange's Arena API with the person's token, and Exchange's /arena links point at /ai/arena.
+const assert = require('node:assert/strict');
+const express = require('express');
+const { createArenaRoutes } = require('../server/http/arena');
+
+(async () => {
+ const app = express();
+ const calls = [];
+ app.use((req, res, next) => {
+ if (req.get('x-test-user')) { req.user = { id: 'test' }; req.token = 'test-token'; }
+ next();
+ });
+ app.use(createArenaRoutes({
+ config: { baseUrl: 'https://openvibe.report' },
+ fetchImpl: async (url, opts) => {
+ calls.push({ url, opts });
+ const body = opts.method === 'POST' ? { location: '/ai/arena/round1' } : { html: '<form action="/arena/ask"><button>Ask</button></form>' };
+ return new Response(JSON.stringify(body), { status: 200 });
+ },
+ }));
+ const server = app.listen(0, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ try {
+ const anon = await fetch(`${base}/ai/arena`, { redirect: 'manual' });
+ assert.equal(anon.status, 200, 'anyone reads the Arena');
+ const page = await fetch(`${base}/ai/arena`, { headers: { 'x-test-user': '1' } });
+ assert.ok((await page.text()).includes('action="/ai/arena"'), "Exchange's /arena links point here");
+ const bad = await fetch(`${base}/ai/arena`, { method: 'POST', headers: { 'x-test-user': '1', origin: 'https://evil.example' } });
+ assert.equal(bad.status, 403, 'another Origin may not post');
+ const signedOut = await fetch(`${base}/ai/arena`, { method: 'POST', redirect: 'manual', headers: { origin: 'https://openvibe.report' } });
+ assert.equal(signedOut.status, 303, 'a signed-out POST goes to sign-in');
+ assert.match(signedOut.headers.get('location'), /^\/auth\/login\?next=/);
+ const vote = await fetch(`${base}/ai/arena/round1/vote`, {
+ method: 'POST', redirect: 'manual',
+ headers: { 'x-test-user': '1', origin: 'https://openvibe.report', 'content-type': 'application/x-www-form-urlencoded' },
+ body: 'outcome=left',
+ });
+ assert.equal(vote.status, 303);
+ assert.ok(calls.at(-1).url.endsWith('/rounds/round1/votes'));
+ assert.equal(JSON.parse(calls.at(-1).opts.body).outcome, 'left');
+ assert.equal(calls.at(-1).opts.headers.authorization, 'Bearer test-token', "the person's own token");
+ console.log('Report Arena: sign-in, Origin, the API pass-through and redirects passed');
+ } finally {
+ await new Promise((r) => server.close(r));
+ }
+})().catch((e) => { console.error(e); process.exitCode = 1; });