openvibers/openvibe.report Commits

Host Bench-backed Exchange Arena on Report (#142)

OP OpenVibers committed 5 days ago · 0cd285894b · 4 files, +123 −0

* Host the Arena on Report using Exchange rounds and Bench votes

* Handle Arena privacy default redirects in Report

* Link Arena results to Report rankings

* The Arena on Report, written readably: the pass-through documented, and two more checks

Same behaviour, in the repo's style (it was minified onto single lines). The test also checks a
signed-out POST goes to sign-in and that the person's own token goes to Exchange.

Changes

ddiff --git a/public/css/arena.css b/public/css/arena.css
nnew file mode 100644
iindex 0000000..70d4b02
---- /dev/null
++++ b/public/css/arena.css
@@@ -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)); }
++}
ddiff --git a/server/app.js b/server/app.js
iindex 3c78a33..8710ef0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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'),
ddiff --git a/server/http/arena.js b/server/http/arena.js
nnew file mode 100644
iindex 0000000..c430b16
---- /dev/null
++++ b/server/http/arena.js
@@@ -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 };
ddiff --git a/test/arena-page.test.js b/test/arena-page.test.js
nnew file mode 100644
iindex 0000000..d6f1aae
---- /dev/null
++++ b/test/arena-page.test.js
@@@ -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; });