openvibers/openvibe.exchange Commits

Build the Exchange API platform, dashboard and search pages (#138)

OP OpenVibers committed 4 days ago · 8899ad9429 · 39 files, +1311 −240

* Build Exchange API catalogue, dashboard, search and supply console

* Avoid showing an unsupported share control in the Report Arena

* Dashboard design pass: cards, the kit's tab rail, usage that reads; the home's code and copy

Dashboard (server/views/dashboard.js, its own .xd scope away from the marketing .xp grid rules):
- the kit's tab rail; Keys as cards with prefix, cap, models and a small Revoke; New key as a card
  with hints under the fields (0 cap = free models only) and the secret on its own page;
- Usage: totals (spent, requests, tokens), requests per day as bars, then one two-line row per
  day, key and model with key names, not ids; CSV beside the title;
- Credits, Deployments and Settings say in one line what lives there; settings rows wrap.
Home: the curl sample scrolls in its box instead of breaking a URL mid-word or widening the
page; the reasons and the pricing line speak to the reader, not of the owner.
scripts/shoot-platform.js shoots home, the tabs and Search at 390 and 1280.

* New key: a $5 default cap, and the hint says how a call holds part of it

* Merge main; a model keeps its full page, with the providers tab's new columns

main moved chat to Chat (#136) and kept the detailed model page; this branch had replaced that page
with a thinner one. The model page is main's again (every offer's privacy label and data policy,
presets, curl and SDK with the exact id) plus what the plan adds: first token and error rate
columns, a Fixed price badge, requests a day as bars, and How it ranks beside Try it. The nav is
the plan's; main's chat export and /chat 301 stay beside the Arena API.

Changes

ddiff --git a/docs/platform-review.md b/docs/platform-review.md
nnew file mode 100644
iindex 0000000..7b35a8d
---- /dev/null
++++ b/docs/platform-review.md
@@@ -0,0 +1,13 @@
++# API platform review
++
++Home, Models, model detail, Dashboard (keys, usage/CSV, credits, deployments and settings), Search and owner supply console use the AI platform APIs in the companion PR. Arena reads and writes use the authenticated Exchange bridge and Report hosts the page. Arena and supply navigation use permanent redirects.
++
++The Chat compare move (Chat #419 / Exchange #136) is not merged. Persona and private-chat migration and their redirects must follow it; they are not implemented here. These changes should remain in review until that dependency and the library releases are coordinated. No production changes or provider purchases were performed.
++
++## Screenshots
++
++The 16 images in `screenshots/` exercise home, Models, model detail and Dashboard Keys at 390 and 1280 pixels in light and dark themes. They use local fake catalogue/account data, not production measurements. `checks.json` records the actual painted mode, document width and browser errors. No horizontal overflow or uncaught page errors were observed. Shared Network theme/frame requests report localhost CORS failures (and some unavailable remote resources); these screenshots are layout checks, not evidence of live cross-service integration.
++
++## Validation
++
++`node --check` on changed JavaScript; `ov test` full suite once, followed by focused fixes for intended redirects, Arena fragments, legacy pools and Dashboard CSV escaping. Final focused Arena and Dashboard tests pass. API keys, search and supply backend validation is recorded in the AI PR.
ddiff --git a/docs/screenshots/checks.json b/docs/screenshots/checks.json
nnew file mode 100644
iindex 0000000..fd09251
---- /dev/null
++++ b/docs/screenshots/checks.json
@@@ -0,0 +1,146 @@
++[
++ {
++ "name": "home",
++ "width": 390,
++ "scheme": "dark",
++ "painted": "dark",
++ "docWidth": 390,
++ "errors": [],
++ "consoleErrors": 4
++ },
++ {
++ "name": "models",
++ "width": 390,
++ "scheme": "dark",
++ "painted": "dark",
++ "docWidth": 390,
++ "errors": [],
++ "consoleErrors": 4
++ },
++ {
++ "name": "model",
++ "width": 390,
++ "scheme": "dark",
++ "painted": "dark",
++ "docWidth": 390,
++ "errors": [],
++ "consoleErrors": 4
++ },
++ {
++ "name": "dashboard",
++ "width": 390,
++ "scheme": "dark",
++ "painted": "dark",
++ "docWidth": 390,
++ "errors": [],
++ "consoleErrors": 6
++ },
++ {
++ "name": "home",
++ "width": 390,
++ "scheme": "light",
++ "painted": "light",
++ "docWidth": 390,
++ "errors": [],
++ "consoleErrors": 4
++ },
++ {
++ "name": "models",
++ "width": 390,
++ "scheme": "light",
++ "painted": "light",
++ "docWidth": 390,
++ "errors": [],
++ "consoleErrors": 4
++ },
++ {
++ "name": "model",
++ "width": 390,
++ "scheme": "light",
++ "painted": "light",
++ "docWidth": 390,
++ "errors": [],
++ "consoleErrors": 4
++ },
++ {
++ "name": "dashboard",
++ "width": 390,
++ "scheme": "light",
++ "painted": "light",
++ "docWidth": 390,
++ "errors": [],
++ "consoleErrors": 6
++ },
++ {
++ "name": "home",
++ "width": 1280,
++ "scheme": "dark",
++ "painted": "dark",
++ "docWidth": 1280,
++ "errors": [],
++ "consoleErrors": 4
++ },
++ {
++ "name": "models",
++ "width": 1280,
++ "scheme": "dark",
++ "painted": "dark",
++ "docWidth": 1280,
++ "errors": [],
++ "consoleErrors": 6
++ },
++ {
++ "name": "model",
++ "width": 1280,
++ "scheme": "dark",
++ "painted": "dark",
++ "docWidth": 1280,
++ "errors": [],
++ "consoleErrors": 6
++ },
++ {
++ "name": "dashboard",
++ "width": 1280,
++ "scheme": "dark",
++ "painted": "dark",
++ "docWidth": 1280,
++ "errors": [],
++ "consoleErrors": 6
++ },
++ {
++ "name": "home",
++ "width": 1280,
++ "scheme": "light",
++ "painted": "light",
++ "docWidth": 1280,
++ "errors": [],
++ "consoleErrors": 4
++ },
++ {
++ "name": "models",
++ "width": 1280,
++ "scheme": "light",
++ "painted": "light",
++ "docWidth": 1280,
++ "errors": [],
++ "consoleErrors": 6
++ },
++ {
++ "name": "model",
++ "width": 1280,
++ "scheme": "light",
++ "painted": "light",
++ "docWidth": 1280,
++ "errors": [],
++ "consoleErrors": 6
++ },
++ {
++ "name": "dashboard",
++ "width": 1280,
++ "scheme": "light",
++ "painted": "light",
++ "docWidth": 1280,
++ "errors": [],
++ "consoleErrors": 6
++ }
++]
ddiff --git a/docs/screenshots/dashboard-1280-dark.png b/docs/screenshots/dashboard-1280-dark.png
nnew file mode 100644
iindex 0000000..37b51be
BBinary files /dev/null and b/docs/screenshots/dashboard-1280-dark.png differ
ddiff --git a/docs/screenshots/dashboard-1280-light.png b/docs/screenshots/dashboard-1280-light.png
nnew file mode 100644
iindex 0000000..98fcb94
BBinary files /dev/null and b/docs/screenshots/dashboard-1280-light.png differ
ddiff --git a/docs/screenshots/dashboard-390-dark.png b/docs/screenshots/dashboard-390-dark.png
nnew file mode 100644
iindex 0000000..cfb95ea
BBinary files /dev/null and b/docs/screenshots/dashboard-390-dark.png differ
ddiff --git a/docs/screenshots/dashboard-390-light.png b/docs/screenshots/dashboard-390-light.png
nnew file mode 100644
iindex 0000000..3525e4a
BBinary files /dev/null and b/docs/screenshots/dashboard-390-light.png differ
ddiff --git a/docs/screenshots/home-1280-dark.png b/docs/screenshots/home-1280-dark.png
nnew file mode 100644
iindex 0000000..905b404
BBinary files /dev/null and b/docs/screenshots/home-1280-dark.png differ
ddiff --git a/docs/screenshots/home-1280-light.png b/docs/screenshots/home-1280-light.png
nnew file mode 100644
iindex 0000000..ea3b8de
BBinary files /dev/null and b/docs/screenshots/home-1280-light.png differ
ddiff --git a/docs/screenshots/home-390-dark.png b/docs/screenshots/home-390-dark.png
nnew file mode 100644
iindex 0000000..05d961e
BBinary files /dev/null and b/docs/screenshots/home-390-dark.png differ
ddiff --git a/docs/screenshots/home-390-light.png b/docs/screenshots/home-390-light.png
nnew file mode 100644
iindex 0000000..be50a96
BBinary files /dev/null and b/docs/screenshots/home-390-light.png differ
ddiff --git a/docs/screenshots/model-1280-dark.png b/docs/screenshots/model-1280-dark.png
nnew file mode 100644
iindex 0000000..68efb63
BBinary files /dev/null and b/docs/screenshots/model-1280-dark.png differ
ddiff --git a/docs/screenshots/model-1280-light.png b/docs/screenshots/model-1280-light.png
nnew file mode 100644
iindex 0000000..fb38f24
BBinary files /dev/null and b/docs/screenshots/model-1280-light.png differ
ddiff --git a/docs/screenshots/model-390-dark.png b/docs/screenshots/model-390-dark.png
nnew file mode 100644
iindex 0000000..6ae78b2
BBinary files /dev/null and b/docs/screenshots/model-390-dark.png differ
ddiff --git a/docs/screenshots/model-390-light.png b/docs/screenshots/model-390-light.png
nnew file mode 100644
iindex 0000000..3517189
BBinary files /dev/null and b/docs/screenshots/model-390-light.png differ
ddiff --git a/docs/screenshots/models-1280-dark.png b/docs/screenshots/models-1280-dark.png
nnew file mode 100644
iindex 0000000..f01302a
BBinary files /dev/null and b/docs/screenshots/models-1280-dark.png differ
ddiff --git a/docs/screenshots/models-1280-light.png b/docs/screenshots/models-1280-light.png
nnew file mode 100644
iindex 0000000..bda9d3a
BBinary files /dev/null and b/docs/screenshots/models-1280-light.png differ
ddiff --git a/docs/screenshots/models-390-dark.png b/docs/screenshots/models-390-dark.png
nnew file mode 100644
iindex 0000000..5683d75
BBinary files /dev/null and b/docs/screenshots/models-390-dark.png differ
ddiff --git a/docs/screenshots/models-390-light.png b/docs/screenshots/models-390-light.png
nnew file mode 100644
iindex 0000000..b46944a
BBinary files /dev/null and b/docs/screenshots/models-390-light.png differ
ddiff --git a/public/css/platform.css b/public/css/platform.css
nnew file mode 100644
iindex 0000000..a512e9e
---- /dev/null
++++ b/public/css/platform.css
@@@ -0,0 +1,228 @@
++.xp {
++ padding-block: 40px;
++ display: grid;
++ gap: 28px;
++}
++.xp-hero {
++ max-width: 850px;
++}
++.xp h1 {
++ font-size: clamp(2rem, 5vw, 3.6rem);
++ line-height: 1.1;
++ letter-spacing: -0.04em;
++}
++.xp a,
++.xp button,
++.xp summary {
++ min-height: 44px;
++ display: inline-flex;
++ align-items: center;
++}
++.xp input,
++.xp select {
++ min-height: 44px;
++ max-width: 100%;
++}
++.xp pre {
++ max-width: 100%;
++ overflow: auto;
++}
++.xp-models {
++ max-width: 100%;
++ overflow: auto;
++}
++.xp table {
++ border-collapse: collapse;
++ width: 100%;
++ text-align: left;
++}
++.xp th,
++.xp td {
++ padding: 16px 12px;
++ border-bottom: 1px solid var(--border, #8884);
++ vertical-align: middle;
++}
++.xp td small,
++.xp td:last-child a {
++ display: block;
++}
++.xp-reasons {
++ display: grid;
++ grid-template-columns: repeat(3, minmax(0, 1fr));
++ gap: 16px;
++}
++.xp-activity label {
++ display: flex;
++ gap: 16px;
++}
++.xp label {
++ display: grid;
++ gap: 6px;
++}
++.xp form:not(.ovk-find) {
++ display: grid;
++ gap: 16px;
++ max-width: 640px;
++}
++.xp nav {
++ display: flex;
++ flex-wrap: wrap;
++ gap: 8px;
++}
++.xp details {
++ border-bottom: 1px solid var(--border, #8884);
++}
++.xp code {
++ overflow-wrap: anywhere;
++}
++.xp .xp-tabs a {
++ padding: 8px 16px;
++}
++.xp-search-results {
++ display: grid;
++ grid-template-columns: repeat(2, minmax(0, 1fr));
++ gap: 20px;
++}
++@media (max-width: 700px) {
++ .xp {
++ padding-block: 24px;
++ }
++ .xp-reasons,
++ .xp-search-results {
++ grid-template-columns: minmax(0, 1fr);
++ }
++ .xp table,
++ .xp tbody,
++ .xp tr,
++ .xp td {
++ display: block;
++ }
++ .xp thead {
++ display: none;
++ }
++ .xp tr {
++ padding: 12px 0;
++ border-bottom: 1px solid var(--border, #8884);
++ }
++ .xp td {
++ border: 0;
++ padding: 4px 8px;
++ overflow-wrap: anywhere;
++ }
++ .xp td[data-label]:before {
++ content: attr(data-label) ': ';
++ opacity: 0.7;
++ }
++ .xp td:last-child a {
++ display: inline-flex;
++ margin-right: 12px;
++ }
++ .xp h1 {
++ font-size: 2.2rem;
++ }
++}
++
++.xp input:not([type='checkbox']),
++.xp select {
++ padding: 10px 12px;
++ border: 1px solid var(--ovk-line);
++ border-radius: 10px;
++ background: var(--ovk-panel);
++ color: var(--ovk-text);
++ font: inherit;
++}
++.xp .ovk-find {
++ max-width: 640px;
++}
++.xp .ovk-find input {
++ border: 0;
++ background: transparent;
++}
++.xp .ovk-find button {
++ flex-shrink: 0;
++}
++.xp .xp-tabs {
++ gap: 4px;
++}
++.xp .xp-tabs a[aria-current='page'] {
++ background: var(--ovk-panel-2);
++ border-radius: 10px;
++ text-decoration: none;
++}
++
++/* The Dashboard (Opus, 2026-11-09): its own scope, so the marketing pages' .xp rules (grid gaps, every link a 44 px
++ flex box) stay off it. Cards on the page surface, the kit's tab rail, two-line rows, numbers in tabular figures. */
++.xd { padding-block: 32px 48px; max-width: 880px; }
++.xd .ovk-tabs { margin-top: 4px; margin-bottom: 4px; }
++.xd-body { display: grid; gap: 16px; margin-top: 12px; }
++.xd-card { padding: 18px 18px 20px; border: 1px solid var(--ovk-line); border-radius: var(--ov-radius-2, 16px); background: var(--ovk-panel); }
++.xd-card > h2, .xd-title h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 1.12rem; letter-spacing: -.01em; }
++.xd-card > h2.xd-sub { font-size: 1rem; }
++.xd-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
++.xd-title h2 { margin: 0; }
++.xd-note { margin: 0 0 14px; color: var(--ovk-muted); font-size: .92rem; line-height: 1.5; max-width: 60ch; }
++.xd-actions { margin: 0 0 14px; display: flex; flex-wrap: wrap; gap: 8px; }
++.xd-chip { display: inline-flex; align-items: center; margin-left: 8px; padding: 1px 8px; border-radius: 999px; font-size: .74rem; color: var(--ovk-muted); background: var(--ovk-panel-2); vertical-align: 1px; }
++
++.xd-keys { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++.xd-key { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; align-items: center; padding: 12px 14px; border-radius: var(--ov-radius-1, 12px); background: var(--ovk-panel-2); }
++.xd-key-main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; }
++.xd-key-main strong { font-size: 1rem; overflow-wrap: anywhere; }
++.xd-key-main code { font-size: .84rem; color: var(--ovk-muted); }
++.xd-key-meta { grid-column: 1; margin: 0; color: var(--ovk-muted); font-size: .86rem; }
++.xd-key-act { grid-column: 2; grid-row: 1 / span 2; margin: 0; }
++.xd-key.is-revoked { opacity: .62; }
++
++.xd-form { display: grid; gap: 16px; max-width: 520px; margin: 0; }
++.xd-form label { display: grid; gap: 6px; }
++.xd-form label > span:first-child { font-weight: 600; font-size: .92rem; }
++.xd-form small { color: var(--ovk-muted); font-size: .82rem; line-height: 1.45; }
++.xd-form input { min-height: 44px; box-sizing: border-box; width: 100%; padding: 10px 12px; border: 1px solid var(--ovk-line); border-radius: 10px; background: var(--ovk-bg, transparent); color: var(--ovk-text); font: inherit; }
++.xd-form input:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
++.xd-money { position: relative; display: block; }
++.xd-money > span { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--ovk-muted); pointer-events: none; }
++.xd-money input { padding-left: 26px; font-variant-numeric: tabular-nums; }
++.xd-form .ovk-btn { justify-self: start; min-height: 44px; }
++.xd-secret .ovk-snippet, .xd-secret pre { margin: 0 0 14px; }
++
++.xd-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0 0 16px; }
++.xd-stats > div { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-radius: var(--ov-radius-1, 12px); background: var(--ovk-panel-2); }
++.xd-stats dd { margin: 0; font-size: 1.3rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
++.xd-stats dt { color: var(--ovk-muted); font-size: .8rem; }
++.xd-bars { display: flex; align-items: flex-end; gap: 4px; height: 96px; padding-bottom: 18px; position: relative; }
++.xd-bars > span { position: relative; flex: 1 1 0; height: 100%; display: flex; align-items: flex-end; }
++.xd-bars i { display: block; width: 100%; border-radius: 4px 4px 2px 2px; background: color-mix(in srgb, var(--ovk-accent) 70%, transparent); }
++.xd-bars b { position: absolute; left: 0; right: 0; bottom: -18px; text-align: center; font-weight: 400; font-size: .7rem; color: var(--ovk-muted); font-variant-numeric: tabular-nums; }
++.xd-day { margin: 14px 0 6px; font-size: .78rem; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--ovk-muted); }
++.xd-day:first-of-type { margin-top: 2px; }
++.xd-use { list-style: none; margin: 0; padding: 0; }
++.xd-use li { padding: 10px 0; border-top: 1px solid var(--ovk-line); }
++.xd-use li:first-child { border-top: 0; }
++.xd-use-top { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
++.xd-use-top code { font-size: .88rem; overflow-wrap: anywhere; min-width: 0; }
++.xd-use-top strong { font-variant-numeric: tabular-nums; }
++.xd-use p { margin: 2px 0 0; color: var(--ovk-muted); font-size: .84rem; }
++.xd .ovk-list--flush { margin: 0 -18px -20px; }
++@media (max-width: 520px) {
++ .xd { padding-block: 20px 40px; }
++ .xd-card { padding: 16px 14px 18px; }
++ .xd-stats dd { font-size: 1.12rem; }
++ .xd .ovk-list--flush { margin: 0 -14px -18px; }
++}
++/* Settings rows say what each page does in full: their line wraps instead of trailing off. */
++.xd .ovk-row-sub > span:first-child { white-space: normal; overflow: visible; }
++
++/* Code samples scroll sideways on a phone; a URL never breaks mid-word (.xp code wraps anywhere for inline code). */
++.xp pre code, .xp pre { overflow-wrap: normal; word-break: normal; white-space: pre; }
++/* Grid tracks never grow past the page for a long line (a pre scrolls inside its own box). */
++.xp > *, .xp-hero, .xp .ovk-snip { min-width: 0; max-width: 100%; }
++.xp .ovk-snip pre { overflow-x: auto; }
++
++/* A model's activity: requests a day as bars, the day of the month under each. */
++.ex-act { list-style: none; display: flex; align-items: flex-end; gap: 4px; height: 110px; margin: 0; padding: 0 0 18px; }
++.ex-act li { position: relative; flex: 1 1 0; height: 100%; display: flex; align-items: flex-end; }
++.ex-act-bar { display: block; width: 100%; border-radius: 4px 4px 2px 2px; background: color-mix(in srgb, var(--ovk-accent) 70%, transparent); }
++.ex-act-day { position: absolute; left: 0; right: 0; bottom: -18px; text-align: center; font-size: .7rem; color: var(--ovk-muted); font-variant-numeric: tabular-nums; }
++.ex-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++
++.ex-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; }
ddiff --git a/scripts/shoot-platform.js b/scripts/shoot-platform.js
nnew file mode 100644
iindex 0000000..c6b8052
---- /dev/null
++++ b/scripts/shoot-platform.js
@@@ -0,0 +1,68 @@
++'use strict';
++/**
++ * The design-review screenshots for the API platform (Opus, 2026-11-09): home, the Dashboard's tabs and Search, at 390
++ * and 1280, dark and light. It boots the real app on a temp database with a mock Network and a stand-in AI holding two
++ * keys and a week of usage, and drives headless Chrome. It checks the width (scrollWidth === viewport) and tap
++ * targets at 390, and says what it found. The shots go outside the repository (never committed).
++ *
++ * node scripts/shoot-platform.js [outDir] [paths, comma separated]
++ */
++const path = require('path');
++const os = require('os');
++const { boot } = require('../test/helpers/boot');
++const { createFakeAi } = require('../test/helpers/ai');
++const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');
++
++const OUT = path.resolve(process.argv[2] || path.join(os.tmpdir(), 'exchange-platform-shots'));
++const PATHS = (process.argv[3] || '/,/dashboard/keys,/dashboard/usage,/dashboard/credits,/dashboard/deployments,/dashboard/settings,/search').split(',');
++
++const KEYS = [
++ { id: 'xkey_1', name: 'My application', prefix: 'ovx_demo12', monthly_cap_usd: 10, allowed_models: [], created_at: '2026-11-08T10:00:00Z', revoked_at: null },
++ { id: 'xkey_2', name: 'Night batch jobs', prefix: 'ovx_n8Kq3z', monthly_cap_usd: 25, allowed_models: ['deepseek/deepseek-chat', 'qwen/qwen3-32b'], created_at: '2026-11-01T10:00:00Z', revoked_at: null },
++];
++const USAGE = [];
++for (let d = 1; d <= 7; d++) {
++ USAGE.push({ day: `2026-11-0${d}`, key_id: 'xkey_1', model: 'deepseek/deepseek-chat', status: 'settled', requests: 40 * d, tokens_in: 9000 * d, tokens_out: 4000 * d, cost_usd: 0.004 * d });
++ if (d % 2) USAGE.push({ day: `2026-11-0${d}`, key_id: 'xkey_2', model: 'qwen/qwen3-32b', status: 'settled', requests: 12 * d, tokens_in: 3000 * d, tokens_out: 1500 * d, cost_usd: 0.002 * d });
++}
++
++(async () => {
++ const ai = { ...createFakeAi(), meRequest: async (_token, p) => (p.endsWith('key-usage') ? { rows: USAGE } : { keys: KEYS }) };
++ const t = await boot({ ai });
++ const alice = t.network.addUser('alice');
++ const cookie = t.signIn(alice);
++ const url = new URL(t.base);
++ const b = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
++ require('fs').mkdirSync(OUT, { recursive: true });
++ try {
++ for (const [width, scheme] of [[390, 'dark'], [390, 'light'], [1280, 'dark']]) {
++ const ctx = await b.newContext({ viewport: { width, height: 900 }, colorScheme: scheme, isMobile: width < 700, hasTouch: width < 700 });
++ await ctx.addCookies([
++ { name: 'exchange_at', value: cookie.split('=')[1], domain: url.hostname, path: '/' },
++ { name: 'ov_token', value: cookie.split('=')[1], domain: url.hostname, path: '/' },
++ ]);
++ const pg = await ctx.newPage();
++ const errors = []; pg.on('pageerror', (e) => errors.push(e.message));
++ for (const p of PATHS) {
++ const r = await pg.goto(t.base + p, { waitUntil: 'load' }).catch(() => null);
++ await pg.waitForTimeout(400);
++ const info = await pg.evaluate(() => {
++ const small = [...document.querySelectorAll('main a, main button, main input, main select, main summary')]
++ .filter((el) => { const b = el.getBoundingClientRect(); return b.width > 0 && b.height > 0 && b.height < 44 && getComputedStyle(el).display !== 'inline'; })
++ .map((el) => `${el.tagName.toLowerCase()}:${(el.textContent || el.name || '').trim().slice(0, 18)}:${Math.round(el.getBoundingClientRect().height)}`);
++ return { over: document.documentElement.scrollWidth > innerWidth, small: small.slice(0, 6) };
++ });
++ const name = `${p === '/' ? 'home' : p.replace(/^\//, '').replace(/\//g, '-')}-${width}-${scheme}`;
++ // The app's boot helper captures console output: the findings go to a file beside the shots.
++ require('fs').appendFileSync(path.join(OUT, 'results.txt'), `${[name, r && r.status(), info.over ? 'OVERFLOW' : 'fits', width < 700 && info.small.length ? `small: ${info.small.join(' ')}` : ''].join(' ')}\n`);
++ await pg.screenshot({ path: path.join(OUT, `${name}.png`), fullPage: true });
++ }
++ if (errors.length) require('fs').appendFileSync(path.join(OUT, 'results.txt'), `errors ${errors.slice(0, 3).join(' | ')}\n`);
++ await ctx.close();
++ }
++ } finally {
++ await b.close();
++ await t.close();
++ }
++ console.log('shots in', OUT);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/server/app.js b/server/app.js
iindex d6a6f5a..044d3b0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -214,6 +214,19 @@ async function createApp(opts = {}) {
app.locals.sharingTimer = timer;
}
++ const arenaApi = express.Router();
++ arenaApi.use(express.json({ limit:'64kb' }));
++ arenaApi.use(principal.middleware);
++ arenaApi.use((req,res,next) => {
++ if (req.method !== 'GET' && (!req.headers.authorization || req.principal?.kind !== 'user')) return res.sendStatus(401);
++ if (req.principal?.kind === 'user') req.viewer = req.principal;
++ req.arenaApi = true;
++ const path = req.path;
++ req.url = path === '/rounds' ? (req.method === 'POST' ? '/arena/ask' : '/arena') : path.replace(/^\/rounds\//, '/arena/').replace(/\/votes$/, '/vote');
++ next();
++ });
++ arenaApi.use(createArenaRoutes(ctx));
++ app.use('/api/v1/arena', arenaApi);
// Chat imports only the signed-in person's unsealed history. The service token must carry
// the narrow export capability; Chat supplies the subject it just authenticated.
app.get('/internal/exchange/chat/export', principal.middleware, async (req, res) => {
@@@ -271,10 +284,15 @@ async function createApp(opts = {}) {
agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });
app.use(agent);
}
++ app.get(['/arena', '/arena/*'], (req,res) => res.redirect(301, 'https://openvibe.report/ai' + req.originalUrl));
++ app.get(['/sell', '/sell/*'], (_req,res) => res.redirect(301, 'https://openvibe.link'));
++ app.get('/ai', (_req,res) => res.redirect(301, 'https://openvibe.report/ai/exchange'));
// The companions routes come before the pages router: /companions, /c/... and /talk/...
app.use(createCompanionRoutes(ctx));
// The Arena and the rankings: /arena, /arena/:id and /rankings.
app.use(createArenaRoutes(ctx));
++ app.use(require('./http/dashboard').createDashboardRoutes(ctx));
++ app.use(require('./http/search').createSearchRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use(createConsoleRoutes(ctx));
ddiff --git a/server/http/arena.js b/server/http/arena.js
iindex 83e6752..59aeee7 100644
---- a/server/http/arena.js
++++ b/server/http/arena.js
@@@ -19,7 +19,7 @@ const ovServe = require('openvibe-shared/serve');
const kit = require('openvibe-shared/kit');
const { asyncRouter } = require('./router');
const { sameOrigin } = require('./principal');
--const { esc, raw } = require('../render/html');
++const { esc, raw, render } = require('../render/html');
const { send } = require('../render/layout');
const views = require('../views/arena');
const exchangeViews = require('../views/exchange');
@@@ -71,7 +71,7 @@ function createArenaRoutes(ctx) {
const { config, ai, bench, arenaStore, sharing, log } = ctx;
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
-- 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) => req.arenaApi ? res.status(status).set('Cache-Control','no-store').json({ title:o.title, html:render(o.body) }) : send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const owner = (req) => req.viewer.subject;
const signInPage = (req, res, { title, text, next, status = 401 }) => page(req, res, {
@@@ -121,7 +121,7 @@ function createArenaRoutes(ctx) {
const vote = await arenaStore.voteOf(owner(req), round.id);
return page(req, res, {
title: 'The Arena', index: false, head: shareHead,
-- body: raw(views.arenaRound({ round, vote, revealed: Boolean(vote), problem: problem || notice, config, action: shareButton(round) })),
++ body: raw(views.arenaRound({ round, vote, revealed: Boolean(vote), problem: problem || notice, config, action: req.arenaApi ? '' : shareButton(round) })),
}, status);
}
@@@ -130,16 +130,16 @@ function createArenaRoutes(ctx) {
/** The person's default for new rounds ("New rounds: Only me"), and what to do with the existing ones. */
r.post('/arena/defaults', async (req, res) => {
if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Farena');
-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ if (!(req.arenaApi || sameOrigin(req, config.baseUrl))) return notFromHere(req, res);
const body = req.body || {};
const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';
await sharing.setDefault(owner(req), 'arena-round', audience, { applyAll: String(body.apply_all || '') === '1' }).catch((err) => log.warn('[Arena] the round default was not saved:', err.message));
-- return res.redirect(303, '/arena');
++ return req.arenaApi ? res.json({ location: '/ai/arena' }) : res.redirect(303, '/arena');
});
r.post('/arena/ask', async (req, res) => {
if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to enter the Arena', text: 'Two models answer your prompt, blind.', next: '/arena' });
-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ if (!(req.arenaApi || sameOrigin(req, config.baseUrl))) return notFromHere(req, res);
const prompt = String((req.body && req.body.prompt) || '').trim().slice(0, config.arena.maxChars);
if (!prompt) return renderHome(req, res, { problem: 'Write a prompt first.', status: 422 });
@@@ -218,7 +218,7 @@ function createArenaRoutes(ctx) {
// The person's default for new rounds (Only me unless they changed it), then the sheet for this one.
const audience = await sharing.defaultOf(owner(req), 'arena-round').catch(() => 'private');
try { await sharing.register({ subject: owner(req) }, 'arena-round', round.id, audience); } catch (err) { log.warn('[Arena] the round was kept but not registered with the sharing system:', err.message); }
-- return res.redirect(303, `/arena/${round.id}`);
++ return req.arenaApi ? res.status(201).json({ location: `/ai/arena/${round.id}` }) : res.redirect(303, `/arena/${round.id}`);
});
/**
@@@ -246,7 +246,7 @@ function createArenaRoutes(ctx) {
r.post('/arena/:id/vote', async (req, res) => {
if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to vote', text: 'Your vote is a Bench judgement for your account.', next: `/arena/${encodeURIComponent(req.params.id)}` });
-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ if (!(req.arenaApi || sameOrigin(req, config.baseUrl))) return notFromHere(req, res);
const round = await arenaStore.get(owner(req), req.params.id);
if (!round) return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such round', text: 'It may belong to another account.', action: { href: '/arena', label: 'The Arena' } })}</div>`) }, 404);
@@@ -269,7 +269,7 @@ function createArenaRoutes(ctx) {
owner: owner(req), roundId: round.id, outcome,
benchJudgementId: out && out.judgement ? out.judgement.id : null,
});
-- return res.redirect(303, `/arena/${round.id}`);
++ return req.arenaApi ? res.status(201).json({ location: `/ai/arena/${round.id}` }) : res.redirect(303, `/arena/${round.id}`);
} catch (err) {
if (err.code === 'bench.already_judged') {
return renderRound(req, res, round, { notice: 'This vote is already counted in OpenVibe.Bench.', status: 409 });
ddiff --git a/server/http/console.js b/server/http/console.js
iindex 2e10fc2..048ac86 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -6,6 +6,7 @@ const { send } = require('../render/layout');
const { consolePage } = require('../views/console');
const READS = Object.freeze({
++ supply: '/api/v1/admin/console/supply',
overview: '/api/v1/admin/console/overview', providers: '/api/v1/admin/console/providers', models: '/api/v1/models', routes: '/api/v1/routes',
templates: '/api/v1/templates', workflows: '/api/v1/workflows', runs: '/api/v1/admin/console/runs', quotas: '/api/v1/admin/console/quotas',
usage: '/api/v1/admin/console/usage', cache: '/api/v1/admin/console/cache', audit: '/api/v1/admin/console/audit',
@@@ -56,6 +57,13 @@ function createConsoleRoutes({ config, ai }) {
if (!origin) return false;
try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
};
++ r.post('/console/supply', async (req,res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ const body = { model: req.body.model, runtime: req.body.runtime };
++ for (const k of ['input_per_million','output_per_million','hourly_ceiling_usd','monthly_ceiling_usd','required_vram_mb','idle_minutes']) body[k] = Number(req.body[k]);
++ try { await ai.adminRequest('/api/v1/admin/console/supply', { method:'POST', subject:req.viewer.subject, body }); return res.redirect(303,'/console/supply'); }
++ catch { return page(req,res,'supply',{},'Supply settings could not be saved.',502); }
++ });
r.post('/console/providers', async (req, res) => {
if (!sameOrigin(req)) return res.sendStatus(403);
try {
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
nnew file mode 100644
iindex 0000000..8bc8be8
---- /dev/null
++++ b/server/http/dashboard.js
@@@ -0,0 +1,127 @@
++'use strict';
++const express = require('express');
++const { raw } = require('../render/html');
++const { send } = require('../render/layout');
++const { sameOrigin } = require('./principal');
++const views = require('../views/dashboard');
++function createDashboardRoutes({ config, ai }) {
++ const r = express.Router();
++ r.use(express.urlencoded({ extended: false, limit: '32kb' }));
++ const render = (req, res, tab, html, status = 200) =>
++ send(res, status, {
++ viewer: req.viewer,
++ config,
++ path: req.originalUrl,
++ index: false,
++ title: 'Dashboard · ' + tab,
++ body: raw(html),
++ });
++ r.use('/dashboard', (req, res, next) => {
++ res.set('Cache-Control', 'no-store');
++ if (req.viewer?.kind !== 'user')
++ return res.redirect(
++ 303,
++ '/auth/login?next=' +
++ encodeURIComponent('/dashboard' + req.url),
++ );
++ if (req.method !== 'GET' && !sameOrigin(req, config.baseUrl))
++ return res.sendStatus(403);
++ next();
++ });
++ const call = (req, path, options) =>
++ ai.meRequest(req.viewer.token, path, options);
++ const safe = (fn) => async (req, res) => {
++ try {
++ await fn(req, res);
++ } catch {
++ render(req, res, 'Unavailable', views.unavailablePage(), 502);
++ }
++ };
++ r.get(
++ ['/dashboard', '/dashboard/keys'],
++ safe(async (req, res) => {
++ const data = await call(req, '/api/v1/exchange/keys');
++ render(req, res, 'Keys', views.keysPage(data.keys || []));
++ }),
++ );
++ r.post(
++ '/dashboard/keys',
++ safe(async (req, res) => {
++ const data = await call(req, '/api/v1/exchange/keys', {
++ method: 'POST',
++ body: {
++ name: req.body.name,
++ monthly_cap_usd: Number(req.body.monthly_cap_usd),
++ allowed_models: String(req.body.allowed_models || '')
++ .split(',')
++ .map((s) => s.trim())
++ .filter(Boolean),
++ },
++ });
++ render(req, res, 'Keys', views.newKeyPage(data.secret, data.key));
++ }),
++ );
++ r.post(
++ '/dashboard/keys/:id/revoke',
++ safe(async (req, res) => {
++ await call(
++ req,
++ '/api/v1/exchange/keys/' + encodeURIComponent(req.params.id),
++ { method: 'DELETE' },
++ );
++ res.redirect(303, '/dashboard/keys');
++ }),
++ );
++ r.get(
++ ['/dashboard/usage', '/dashboard/usage.csv'],
++ safe(async (req, res) => {
++ const data = await call(req, '/api/v1/exchange/key-usage');
++ const rows = data.rows || [];
++ const keyList = req.path.endsWith('.csv') ? null : await call(req, '/api/v1/exchange/keys').catch(() => ({ keys: [] }));
++ const columns = [
++ 'day',
++ 'key_id',
++ 'model',
++ 'requests',
++ 'tokens_in',
++ 'tokens_out',
++ 'cost_usd',
++ 'status',
++ ];
++ if (req.path.endsWith('.csv')) {
++ const cell = (v) =>
++ '"' +
++ String(v ?? '')
++ .replace(/^([=+@-])/, "'$1")
++ .replace(/"/g, '""') +
++ '"';
++ return res
++ .type('text/csv')
++ .set(
++ 'Content-Disposition',
++ 'attachment; filename="exchange-usage.csv"',
++ )
++ .send(
++ [
++ columns.join(','),
++ ...rows.map((r) =>
++ columns.map((k) => cell(r[k])).join(','),
++ ),
++ ].join('\n'),
++ );
++ }
++ render(req, res, 'Usage', views.usagePage(rows, (keyList && keyList.keys) || []));
++ }),
++ );
++ r.get('/dashboard/credits', (req, res) => render(req, res, 'Credits', views.creditsPage()));
++ r.get(
++ '/dashboard/deployments',
++ safe(async (req, res) => {
++ const data = await ai.ownList(req.viewer.token);
++ render(req, res, 'Deployments', views.deploymentsPage(data.endpoints || []));
++ }),
++ );
++ r.get('/dashboard/settings', (req, res) => render(req, res, 'Settings', views.settingsPage()));
++ return r;
++}
++module.exports = { createDashboardRoutes };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 7a56000..0d70f71 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -27,6 +27,7 @@ const { esc, raw } = require('../render/html');
const marks = require('../views/marks');
const { send } = require('../render/layout');
const views = require('../views/exchange');
++const platform = require('../views/platform');
const credentialViews = require('../views/credentials');
const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 60 });
@@@ -92,16 +93,19 @@ function createPageRoutes(ctx) {
.send(JSON.stringify(doc));
});
++ r.get('/pricing', (req,res) => page(req,res,{title:'Pricing',body:raw('<div class="ex-wrap xp">'+platform.pricing()+'</div>')}));
++ r.get('/docs', (req,res) => res.redirect(301,'/api'));
++
// ── Home ─────────────────────────────────────────────────
r.get('/', async (req, res) => {
-- const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
++ const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(7)]);
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: null,
description: 'Every AI model, one account: live prices, speed and uptime of every offer, from the families on sale today and more as their providers join. Chat, or call one OpenAI-compatible endpoint.',
jsonLd: homeJsonLd(config),
-- body: raw(views.home({ catalog, usage, viewer: req.viewer, q, config })),
++ body: raw(platform.home({ catalog, usage, viewer: req.viewer, q, config })),
});
});
@@@ -115,7 +119,7 @@ function createPageRoutes(ctx) {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: q ? `Models: ${q}` : 'Models',
description: 'Every model on OpenVibe.Exchange with its live offers: price in and out per million tokens, measured speed and uptime, context length and data policy.',
-- body: raw(views.modelsPage({ catalog, q, family, sort, community })),
++ body: raw(platform.modelsPage({ catalog, query: req.query })),
});
});
@@@ -147,6 +151,8 @@ function createPageRoutes(ctx) {
}, status);
}
++ r.get('/models/:id', (req, res) => { const parts = req.params.id.split('/'); req.params.org = parts.shift(); req.params.model = parts.join('/'); return renderModel(req, res); });
++ r.get('/models/:org/:model', (req, res) => renderModel(req, res));
r.get('/m/:org/:model', (req, res) => renderModel(req, res));
r.post('/m/:org/:model/try', (req, res) => res.redirect(301,
`https://openvibe.chat/vibe?models=${encodeURIComponent(`${req.params.org}/${req.params.model}`)}`));
ddiff --git a/server/http/search.js b/server/http/search.js
nnew file mode 100644
iindex 0000000..405fc8e
---- /dev/null
++++ b/server/http/search.js
@@@ -0,0 +1,61 @@
++'use strict';
++const express = require('express');
++const kit = require('openvibe-shared/kit');
++const { esc, raw } = require('../render/html');
++const { send } = require('../render/layout');
++const { sameOrigin } = require('./principal');
++function createSearchRoutes({ config, ai }) {
++ const r = express.Router();
++ r.use(express.urlencoded({ extended: false, limit: '8kb' }));
++ const engines = [
++ ['openvibe', 'OpenVibe Search', 'web'],
++ ['searxng', 'SearXNG', 'web · requires an operator instance'],
++ ['openverse', 'Openverse', 'images · CC licences'],
++ ['commons', 'Wikimedia Commons', 'images · attribution'],
++ ['iconify', 'Iconify', 'icons · SVG'],
++ ];
++ const render = (req, res, results = [], status = 200) =>
++ send(res, status, {
++ viewer: req.viewer,
++ config,
++ path: req.originalUrl,
++ title: 'Search',
++ body: raw(
++ `<div class="ex-wrap xp">${kit.head({ title: 'One search API, many engines', sub: 'One key for models, web search, images and icons.' })}<div class="xp-reasons">${engines.map(([id, name, kind]) => kit.section({ title: name, body: `<p>${esc(kind)}</p><p>$0 per 1,000 · no account required</p><p>Freshness: index dependent · BYOK: not required</p>` })).join('')}</div><h2>Try two engines</h2><form method="post" action="/search"><label>Query<input name="q" required maxlength="500"></label><label>Kind<select name="kind"><option>web</option><option>images</option><option>icons</option></select></label>${['first', 'second'].map((k, i) => `<label>${i ? 'Second' : 'First'} engine<select name="${k}">${engines.map(([id, name]) => `<option value="${id}">${name}</option>`).join('')}</select></label>`).join('')}${kit.button({ label: 'Compare results', type: 'submit' })}</form><div class="xp-search-results">${results.map((x) => `<section><h3>${esc(x.engine)}</h3>${x.error ? `<p>${esc(x.error)}</p>` : `<p>$${Number(x.receipt.cost_usd)} · ${Number(x.receipt.time_ms)} ms</p><ol>${x.results.map((r) => `<li><a href="${esc(r.source_page || r.url)}" rel="noopener noreferrer">${esc(r.title)}</a><p>${esc(r.snippet || r.attribution || '')}</p></li>`).join('')}</ol>`}</section>`).join('')}</div><p>Image search powered by Openverse and Wikimedia Commons. Keep the returned attribution with each image.</p><p>Paid engines and BYOK providers will become available after their owner accounts and prices are configured.</p></div>`,
++ ),
++ });
++ r.get('/search', (req, res) => render(req, res));
++ r.post('/search', async (req, res) => {
++ if (req.viewer?.kind !== 'user')
++ return res.redirect(303, '/auth/login?next=%2Fsearch');
++ if (!sameOrigin(req, config.baseUrl)) return res.sendStatus(403);
++ const kind = ['web', 'images', 'icons'].includes(req.body.kind)
++ ? req.body.kind
++ : 'web';
++ const results = await Promise.all(
++ [req.body.first, req.body.second].map(async (engine) => {
++ try {
++ return {
++ engine,
++ ...(await ai.meRequest(
++ req.viewer.token,
++ '/v1/search' + (kind === 'web' ? '' : '/' + kind),
++ {
++ method: 'POST',
++ body: { q: req.body.q, engine, limit: 6 },
++ },
++ )),
++ };
++ } catch {
++ return {
++ engine,
++ error: 'This engine is unavailable or does not support this search kind.',
++ };
++ }
++ }),
++ );
++ render(req, res, results);
++ });
++ return r;
++}
++module.exports = { createSearchRoutes };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex b51a4fa..4ce3710 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -22,19 +22,11 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// "OpenVibe..."). Icons are the navbar's own glyphs; the brand is the way home.
const NAV = [
{ label: 'Models', href: '/models', icon: 'fa-microchip' },
-- { label: 'Chat', href: 'https://openvibe.chat/vibe', icon: 'fa-comments' },
-- { label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
-- { label: 'Rankings', href: '/rankings', icon: 'fa-chart-line' },
-- { label: 'Start building', href: '/start', icon: 'fa-bolt' },
-- { label: 'API', href: '/api', icon: 'fa-code' },
-- { label: 'Your keys and accounts', href: '/privacy/keys', icon: 'fa-key', more: true },
-- { label: 'Why this model', href: '/why', icon: 'fa-diagram-project', more: true },
-- { label: 'Your own endpoint', href: '/own', icon: 'fa-server', more: true },
-- { label: 'Sell compute', href: '/sell', icon: 'fa-microchip', more: true },
-- { label: 'Uncensored (18+)', href: '/uncensored', icon: 'fa-lock', more: true },
-- { label: 'AI in OpenVibe', href: '/ai', icon: 'fa-circle-nodes', more: true },
-- { label: 'Questions', href: '/faq', icon: 'fa-circle-info', more: true },
-- { label: 'Status', href: '/status', icon: 'fa-gauge', more: true },
++ { label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },
++ { label: 'Pricing', href: '/pricing', icon: 'fa-tag' },
++ { label: 'Docs', href: '/docs', icon: 'fa-code' },
++ { label: 'Status', href: '/status', icon: 'fa-gauge' },
++ { label: 'Rankings', href: 'https://openvibe.report/ai', icon: 'fa-chart-line', more: true },
];
const hashes = new Map();
@@@ -66,6 +58,7 @@ function renderPage(o) {
apiBase: NETWORK_URL,
links: [
...NAV.map((l) => ({ label: l.label, href: l.href, icon: l.icon, ...(l.more ? { more: true } : {}), active: active(pathNow, l.href) })),
++ ...(signedIn ? [{ label: 'Dashboard', href: '/dashboard', icon: 'fa-key', active: active(pathNow, '/dashboard') }] : []),
...(viewer.role === 'owner' ? [{ label: 'AI console', href: '/console', icon: 'fa-sliders', active: active(pathNow, '/console') }] : []),
],
history: { type: 'page', title: o.title || SITE_NAME },
@@@ -103,6 +96,7 @@ ${appIcon.headTags({ site: 'exchange' })}
<link rel="stylesheet" href="${ovServe.url('room.css')}">
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${asset('css/exchange.css')}">
++<link rel="stylesheet" href="${asset('css/platform.css')}">
<script src="${ovServe.url('motion.js')}" defer></script>
<script src="${ovServe.url('kit-web.js')}" defer></script>
<script src="${asset('js/exchange.js')}" defer></script>
ddiff --git a/server/views/console.js b/server/views/console.js
iindex e2f5597..250e1ba 100644
---- a/server/views/console.js
++++ b/server/views/console.js
@@@ -3,7 +3,7 @@
const { esc } = require('../render/html');
const SECTIONS = [
-- ['Overview', '/console'], ['Providers', '/console/providers'], ['Models', '/console/models'],
++ ['Overview', '/console'], ['GPU supply (dry run)', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],
['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],
['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],
['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],
@@@ -21,6 +21,8 @@ function consolePage({ section = 'overview', data = {}, error = null }) {
const nav = SECTIONS.map(([label, href]) => `<a class="ex-console-tab${active === href ? ' is-active' : ''}" href="${href}">${label}</a>`).join('');
const json = esc(JSON.stringify(safe(data), null, 2));
let actions = '';
++ if (section === 'supply') actions = `<p>Dry run only. No GPU is rented or stopped. Missing measurements are shown as missing.</p><form method="post" action="/console/supply" class="ovk-form"><label>Model<input name="model" required></label><label>Runtime<select name="runtime"><option value="vllm">vLLM</option><option value="sglang">SGLang</option></select></label>${[['input_per_million','Input USD / million'],['output_per_million','Output USD / million'],['hourly_ceiling_usd','Hourly spend ceiling USD'],['monthly_ceiling_usd','Monthly spend ceiling USD'],['required_vram_mb','Required VRAM MB'],['idle_minutes','Stop after idle minutes']].map(([key,label])=>`<label>${label}<input name="${key}" type="number" min="0" step="any" required></label>`).join('')}<button class="ovk-button" type="submit">Save prices and ceilings</button></form>`;
++
if (section === 'providers' && Array.isArray(data.providers)) actions = data.providers.map((p) => {
const key = encodeURIComponent(p.key || '');
const next = p.status === 'disabled' ? 'active' : 'disabled';
ddiff --git a/server/views/dashboard.js b/server/views/dashboard.js
nnew file mode 100644
iindex 0000000..9772d92
---- /dev/null
++++ b/server/views/dashboard.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * The Dashboard (Opus's design, 2026-11-09): one page per tab, server-rendered, with the kit's tab rail. Keys are cards
++ * with their prefix, cap and models; a new key's secret shows once on its own page; Usage starts with the totals and
++ * a day-by-day bar, then one two-line row per day, key and model (names, not ids), with the CSV beside the title.
++ * Credits, Deployments and Settings each say in one line what lives there and link to it.
++ */
++const kit = require('openvibe-shared/kit');
++const { esc } = require('../render/html');
++
++const TABS = [['keys', 'Keys'], ['usage', 'Usage'], ['credits', 'Credits'], ['deployments', 'Deployments'], ['settings', 'Settings']];
++const money = (v) => {
++ const n = Number(v) || 0;
++ if (n === 0) return '$0';
++ if (n < 0.01) return `$${Number(n.toPrecision(2))}`;
++ return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
++};
++const count = (v) => {
++ const n = Number(v) || 0;
++ if (n >= 1e9) return `${(n / 1e9).toFixed(n >= 1e10 ? 0 : 1)}B`;
++ if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`;
++ if (n >= 1e3) return `${Number((n / 1e3).toFixed(n >= 1e4 ? 0 : 1))}k`;
++ return n.toLocaleString('en-US');
++};
++const day = (iso) => {
++ const d = new Date(`${String(iso).slice(0, 10)}T12:00:00Z`);
++ return Number.isNaN(d.getTime()) ? String(iso || '') : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
++};
++const plural = (n, one, many = `${one}s`) => `${Number(n).toLocaleString('en-US')} ${Number(n) === 1 ? one : many}`;
++
++/** The frame every tab shares: the page head, the tab rail, the tab's body. */
++function shell(tab, body) {
++ return `<div class="ex-wrap xd">${kit.head({ title: 'Dashboard', sub: 'Keys, usage and endpoints for the Exchange API' })}${kit.tabs({
++ label: 'Dashboard', active: tab, items: TABS.map(([key, label]) => ({ key, label, href: `/dashboard/${key}` })),
++ })}<div class="xd-body">${body}</div></div>`;
++}
++
++function keyCard(k) {
++ const models = Array.isArray(k.allowed_models) && k.allowed_models.length ? plural(k.allowed_models.length, 'model') : 'every model';
++ const made = k.created_at ? `made ${day(k.created_at)}` : '';
++ const revoked = !!k.revoked_at;
++ return `<li class="xd-key${revoked ? ' is-revoked' : ''}"><div class="xd-key-main"><strong>${esc(k.name)}</strong><code>${esc(k.prefix)}…</code></div>`
++ + `<p class="xd-key-meta">${esc([`${money(k.monthly_cap_usd)} a month`, models, revoked ? `revoked ${day(k.revoked_at)}` : made].filter(Boolean).join(' · '))}</p>`
++ + (revoked ? '' : `<form method="post" action="/dashboard/keys/${encodeURIComponent(k.id)}/revoke" class="xd-key-act">${kit.button({ label: 'Revoke', type: 'submit', kind: 'danger', small: true })}</form>`)
++ + '</li>';
++}
++
++function keysPage(keys = []) {
++ const live = keys.filter((k) => !k.revoked_at);
++ const list = keys.length
++ ? `<ul class="xd-keys">${keys.map(keyCard).join('')}</ul>`
++ : kit.empty({ title: 'No keys yet', text: 'Make one below. Its secret shows once, when you make it.' });
++ return shell('keys', `<section class="xd-card"><h2>Your keys${live.length ? `<span class="ovk-count">${live.length}</span>` : ''}</h2>${list}</section>`
++ + `<section class="xd-card" id="new"><h2>New key</h2><p class="xd-note">The secret shows once, when you make it. Calls stop when the month's spend reaches the cap.</p>`
++ + '<form class="xd-form" method="post" action="/dashboard/keys">'
++ + '<label><span>Name</span><input name="name" required maxlength="80" placeholder="My application" autocomplete="off"></label>'
++ + '<label><span>Monthly cap</span><span class="xd-money"><span aria-hidden="true">$</span><input name="monthly_cap_usd" type="number" min="1" step="0.01" value="5" required inputmode="decimal" aria-describedby="cap-hint"></span><small id="cap-hint">Each call holds up to $1 (or its max_cost_usd) until it finishes; the unused part comes back.</small></label>'
++ + '<label><span>Models it may call</span><input name="allowed_models" placeholder="Every model" autocomplete="off" aria-describedby="models-hint"><small id="models-hint">Model ids, comma separated, e.g. deepseek/deepseek-chat. Empty allows every model.</small></label>'
++ + `${kit.button({ label: 'Make the key', type: 'submit' })}</form></section>`);
++}
++
++function newKeyPage(secret, key = null) {
++ return shell('keys', `<section class="xd-card xd-secret"><h2>Copy your key now</h2><p class="xd-note">${key && key.name ? `<strong>${esc(key.name)}</strong>: ` : ''}this is the only time it shows. Keep it in your app's secrets; anyone with it can spend up to its cap.</p>`
++ + `${kit.snippet({ code: secret, id: 'new-key' })}<p class="xd-actions">${kit.button({ href: '/dashboard/keys', label: 'Done' })}</p></section>`);
++}
++
++/** Usage rows from the API (by day, key, model and status) with key names; the newest day first. */
++function usagePage(rows = [], keys = []) {
++ const names = new Map(keys.map((k) => [k.id, k.name]));
++ const totals = rows.reduce((t, r) => ({
++ cost: t.cost + (Number(r.cost_usd) || 0), requests: t.requests + (Number(r.requests) || 0),
++ tokens: t.tokens + (Number(r.tokens_in) || 0) + (Number(r.tokens_out) || 0),
++ }), { cost: 0, requests: 0, tokens: 0 });
++ const days = [...new Set(rows.map((r) => String(r.day).slice(0, 10)))].sort();
++ const head = `<div class="xd-title"><h2>Usage</h2>${rows.length ? `<a class="ovk-btn ovk-btn--quiet ovk-btn--small" href="/dashboard/usage.csv" download><span>CSV</span></a>` : ''}</div>`;
++ if (!rows.length) return shell('usage', `<section class="xd-card">${head}${kit.empty({ title: 'No usage yet', text: 'Calls made with your keys show here by day, key and model, with their tokens and cost.' })}</section>`);
++ const span = days.length > 1 ? `${day(days[0])} – ${day(days[days.length - 1])}` : day(days[0]);
++ const stat = (v, label) => `<div><dd>${esc(v)}</dd><dt>${esc(label)}</dt></div>`;
++ // Requests per day, the last 14 days that have any: bars scaled to the busiest one (the rows below hold the numbers).
++ const shown = days.slice(-14);
++ const perDay = shown.map((d) => [d, rows.filter((r) => String(r.day).slice(0, 10) === d).reduce((n, r) => n + (Number(r.requests) || 0), 0)]);
++ const peak = Math.max(1, ...perDay.map(([, n]) => n));
++ const bars = `<div class="xd-bars" aria-hidden="true">${perDay.map(([d, n]) => `<span title="${esc(`${day(d)}: ${plural(n, 'request')}`)}"><i style="height:${Math.max(4, Math.round((n / peak) * 100))}%"></i><b>${esc(day(d).replace(/^[A-Za-z]+ /, ''))}</b></span>`).join('')}</div>`;
++ const byDay = [...days].reverse().map((d) => {
++ const items = rows.filter((r) => String(r.day).slice(0, 10) === d)
++ .sort((a, b) => (Number(b.cost_usd) || 0) - (Number(a.cost_usd) || 0))
++ .map((r) => {
++ const key = names.get(r.key_id) || r.key_id;
++ const state = r.status && r.status !== 'settled' ? `<span class="xd-chip">${esc(r.status)}</span>` : '';
++ return `<li><div class="xd-use-top"><code>${esc(r.model)}</code><strong>${esc(money(r.cost_usd))}</strong></div>`
++ + `<p>${esc([key, plural(r.requests, 'request'), `${count(r.tokens_in)} in · ${count(r.tokens_out)} out`].join(' · '))}${state}</p></li>`;
++ }).join('');
++ return `<h3 class="xd-day">${esc(day(d))}</h3><ul class="xd-use">${items}</ul>`;
++ }).join('');
++ return shell('usage', `<section class="xd-card">${head}<p class="xd-note">${esc(span)}</p>`
++ + `<dl class="xd-stats">${stat(money(totals.cost), 'spent')}${stat(count(totals.requests), 'requests')}${stat(count(totals.tokens), 'tokens')}</dl>`
++ + `${bars}</section><section class="xd-card"><h2 class="xd-sub">By day, key and model</h2>${byDay}</section>`);
++}
++
++function creditsPage() {
++ return shell('credits', '<section class="xd-card"><h2>Credits</h2><p class="xd-note">Credits pay for managed models. Your balance, top-ups and auto top-up live in your OpenVibe wallet, the same one on every OpenVibe site.</p>'
++ + `<p class="xd-actions">${kit.button({ href: 'https://openvibe.network/my#wallet', label: 'Open your wallet' })}</p>`
++ + '<p class="xd-note">With your own provider key, calls cost the provider\'s price and nothing more: 0% fee, no cap.</p></section>');
++}
++
++function deploymentsPage(endpoints = []) {
++ const rows = endpoints.map((e) => `<li><div class="xd-use-top"><code>${esc(e.model || '')}</code><span class="xd-chip">${esc(e.status || '')}</span></div><p>${esc(e.id || '')}${e.price_per_hour != null ? ` · ${esc(money(e.price_per_hour))} an hour` : ''}</p></li>`).join('');
++ return shell('deployments', '<section class="xd-card"><h2>Deployments</h2><p class="xd-note">Your own endpoint: a model on a GPU of its own, private to you. The hourly price shows before it starts, and it stops when idle.</p>'
++ + `<p class="xd-actions">${kit.button({ href: '/own', label: 'Price an endpoint' })}</p>`
++ + (rows ? `<ul class="xd-use">${rows}</ul>` : kit.empty({ title: 'No deployments yet', text: 'An endpoint you start shows here with its status and price.' }))
++ + '</section>');
++}
++
++function settingsPage() {
++ const item = (href, title, sub) => kit.row({ href, title, sub, trail: kit.icon('chevron') });
++ return shell('settings', `<section class="xd-card"><h2>Settings</h2>${kit.list([
++ item('/privacy/keys', 'Your provider keys', 'Bring your own key at a 0% fee, and choose the data policy for your calls.'),
++ item('/api#privacy', 'Routing and strict privacy', 'Prefer the cheapest or the fastest route; keep prompts out of every log.'),
++ item('/uncensored', 'Adult models (18+)', 'Opt in to uncensored models. The content floor applies on every request.'),
++ ], { flush: true })}</section>`);
++}
++
++function unavailablePage() {
++ return shell('', '<section class="xd-card">' + kit.empty({ title: 'The account API did not answer', text: 'Nothing was changed. Try again in a moment.' }) + '</section>');
++}
++
++module.exports = { keysPage, newKeyPage, usagePage, creditsPage, deploymentsPage, settingsPage, unavailablePage, TABS };
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex 35ee2f0..66e7bb7 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -318,7 +318,9 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
{ key: 'provider', label: 'Offer', primary: true, html: true },
{ key: 'price', label: 'In / out per M', html: true },
{ key: 'speed', label: 'Speed', html: true },
++ { key: 'first', label: 'First token' },
{ key: 'uptime', label: 'Uptime' },
++ { key: 'errors', label: 'Errors' },
{ key: 'privacy', label: 'Privacy', html: true },
{ key: 'policy', label: 'Data policy', html: true },
{ key: 'try', label: 'Try', html: true },
@@@ -333,9 +335,11 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
const refuses = refusalText(o);
return {
provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a> ${communityBadge}${freeTier(o) ? ` ${kit.badge('Free to try')}` : (freePaused(o) ? ` ${kit.badge('Free paused')}` : '')}`,
-- price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}` : '<span class="ex-muted">free capacity</span>',
++ price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}${o.fixed_price ? ` ${kit.badge('Fixed price')}` : ''}` : '<span class="ex-muted">free capacity</span>',
speed: `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,
++ first: o.speed && o.speed.ttft_ms != null ? ms(o.speed.ttft_ms) : '–',
uptime: o.uptime == null ? '–' : pct(o.uptime),
++ errors: o.error_rate == null ? '–' : pct(o.error_rate),
privacy: `${p ? kit.badge(p.label, p.kind) : ''}${refuses ? `<br><small>${esc(refuses)}</small>` : ''}` || '–',
policy: policyText(o.data_policy),
try: `<a class="ex-mini" href="${chatModel(model)}">Try it</a>`,
@@@ -352,14 +356,27 @@ ${kit.head({ title: model.name, sub: `${familyLabel(model)}${model.variant ? `
${stats.length ? kit.stats(stats) : ''}
${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}
${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}
++${activityPanel(model.activity)}
${panel({ title: 'Presets', note: 'A preset fixes the router’s floor, ceiling and policy, and you name it as the model.', id: 'presets', body: presetsBox() })}
--${panel({ title: 'Try it', id: 'try', body: kit.button({ href: chatModel(model), label: `Chat with ${model.name}` }) })}
++${panel({ title: 'Try it', id: 'try', body: `<p class="ex-actions">${kit.button({ href: chatModel(model), label: `Chat with ${model.name}` })} ${kit.button({ href: `https://openvibe.report/ai/models/${encodeURIComponent(model.slug || model.canonical)}`, label: 'How it ranks', kind: 'quiet' })}</p>` })}
${panel({ title: 'Use it from code', id: 'code', body: `${kit.snippet({ code, id: 'model-curl' })}
${kit.snippet({ code: sdk, id: 'model-sdk' })}
<p class="ex-more">The endpoint, keys and usage: <a href="/api">the API</a>.</p>` })}
</div>`;
}
++/** Requests a day across the model's offers, the last 14 days that have any: bars, with the numbers as their labels. */
++function activityPanel(activity) {
++ const days = Array.isArray(activity) ? activity.slice(-14) : [];
++ if (!days.length) return '';
++ const peak = Math.max(1, ...days.map((d) => Number(d.requests) || 0));
++ const bars = days.map((d) => {
++ const n = Number(d.requests) || 0;
++ return `<li><span class="ex-act-bar" style="height:${Math.max(4, Math.round((n / peak) * 100))}%"></span><span class="ex-act-day">${esc(String(d.day).slice(8, 10))}</span><span class="ex-sr">${esc(String(d.day))}: ${n.toLocaleString('en-US')} requests</span></li>`;
++ }).join('');
++ return panel({ title: 'Activity', note: 'Requests a day across every offer.', id: 'activity', body: `<ol class="ex-act">${bars}</ol>` });
++}
++
const PRESETS = [
['openvibe/economy', 'Cheapest', 'The lowest cost per successful answer.'],
['openvibe/fast', 'Fastest', 'The quickest measured offer.'],
ddiff --git a/server/views/platform.js b/server/views/platform.js
nnew file mode 100644
iindex 0000000..308931d
---- /dev/null
++++ b/server/views/platform.js
@@@ -0,0 +1,129 @@
++'use strict';
++const kit = require('openvibe-shared/kit');
++const legacy = require('./exchange');
++const listed = (catalog) => legacy.chatModels(catalog);
++const unavailable = (catalog) =>
++ catalog.unavailable
++ ? '<p role="status">The live catalogue is not reachable right now.</p>'
++ : '';
++const { esc } = require('../render/html');
++const free = (o) =>
++ o.free_now === undefined ? o.free_tier === true : o.free_now === true;
++const paused = (o) => o.free_tier === true && o.free_now === false;
++const id = (m) => m.slug || m.canonical;
++const price = (v) =>
++ v == null
++ ? 'Not priced'
++ : `$${Number(v).toLocaleString('en-US', { maximumFractionDigits: 4 })}`;
++const metric = (v, unit = '') =>
++ v == null
++ ? 'Not measured'
++ : `${Number(v).toLocaleString('en-US', { maximumFractionDigits: 2 })}${unit}`;
++const best = (m) =>
++ [...(m.offers || [])]
++ .filter((o) => o.price)
++ .sort(
++ (a, b) => a.price.in + a.price.out - b.price.in - b.price.out,
++ )[0] ||
++ (m.offers || [])[0] ||
++ {};
++const chat = (m) =>
++ `https://openvibe.chat/vibe?models=${encodeURIComponent(id(m))}`;
++const rank = (m) =>
++ `https://openvibe.report/ai/models/${encodeURIComponent(id(m))}`;
++const section = (title, body) => kit.section({ title, body });
++const code = (m) =>
++ kit.snippet({
++ id: 'platform-curl',
++ code: `curl https://api.openvibe.exchange/v1/chat/completions \\\n -H "Authorization: Bearer $OPENVIBE_KEY" \\\n -H "Content-Type: application/json" \\\n -d '${JSON.stringify({ model: m || 'openvibe/auto', messages: [{ role: 'user', content: 'Hello' }] })}'`,
++ });
++function table(models) {
++ return `<div class="xp-models"><table><thead><tr><th>Model</th><th>Input / output per million</th><th>Tokens/s</th><th>Uptime</th><th>Explore</th></tr></thead><tbody>${models
++ .map((m) => {
++ const o = best(m);
++ return `<tr><td><a href="/models/${encodeURIComponent(id(m))}"><strong>${esc(m.name)}</strong></a><small>${esc(legacy.familyLabel(m))}${(m.offers || []).some(free) ? ' · Free to try' : (m.offers || []).some(paused) ? ' · Free paused' : ''}${m.uncensored ? ' · 18+' : ''}</small></td><td data-label="Input / output per million">${price(o.price?.in)} / ${price(o.price?.out)}${o.fixed_price ? ' · Fixed' : ''}</td><td data-label="Tokens/s">${metric(o.speed?.tokens_per_second)}</td><td data-label="Uptime">${metric(o.uptime == null ? null : o.uptime * 100, '%')}</td><td><a href="${chat(m)}">Try in Chat</a><a href="${rank(m)}">How it ranks →</a></td></tr>`;
++ })
++ .join(
++ '',
++ )}</tbody></table>${!models.length ? '<p>No models match these filters.</p>' : ''}</div>`;
++}
++function pricing() {
++ return section(
++ 'Pricing',
++ `<div class="xp-models"><table><thead><tr><th>Route</th><th>What you pay</th></tr></thead><tbody><tr><td>Your provider key</td><td>Provider price + 0% fee, forever, no cap</td></tr><tr><td>Managed models</td><td>The input and output rates listed on each model</td></tr><tr><td>Our GPUs</td><td>Fixed input and output prices set by the owner</td></tr><tr><td>Your deployment</td><td>The quoted hourly price, shown before starting</td></tr><tr><td>Search</td><td>Engine price per 1,000 searches; free engines first</td></tr></tbody></table></div><p>Paid models open soon. Every price shows before you call, and nothing is charged until then.</p>`,
++ );
++}
++function home({ catalog, usage, viewer }) {
++ const models = listed(catalog);
++ return `<div class="ex-wrap xp">${unavailable(catalog)}<section class="xp-hero"><p class="ex-kicker">OpenVibe.Exchange</p><h1>One API for every model.<br>Pay what the model costs.</h1>${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model', icon: 'search' })}<p>${kit.button({ href: viewer?.kind === 'user' ? '/dashboard/keys#new' : '/auth/login?next=%2Fdashboard%2Fkeys%23new', label: 'Get a key' })} ${kit.button({ href: '/docs', label: 'Read the docs', kind: 'quiet' })}</p>${code()}</section>${kit.stats(
++ [
++ [models.length, 'models'],
++ [
++ metric(
++ usage?.totals
++ ? Number(usage.totals.tokens_in || 0) +
++ Number(usage.totals.tokens_out || 0)
++ : null,
++ ),
++ 'tokens routed this week',
++ ],
++ [
++ metric(catalog.median_ttft_ms, ' ms'),
++ 'median time to first token',
++ ],
++ ],
++ )}${section('Popular models', table([...models].sort((a, b) => (b.requests_7d || 0) - (a.requests_7d || 0)).slice(0, 10)))}<div class="xp-reasons">${section('0% fee on your own key', '<p>Bring your provider key and pay the provider\'s price, nothing more. No cap, no end date.</p>')}${section('The cheapest route', '<p>Every call goes to the cheapest provider that is up. Open models on OpenVibe\'s own GPUs, at a fixed price per million tokens, are coming next.</p>')}${section('Uncensored, 18+', '<p>Uncensored models for adults who opt in. The content floor applies on every request.</p><a href="/uncensored">What the floor covers</a>')}</div>${pricing()}${section('Questions', '<details><summary>Can I use my existing client?</summary><p>Yes. Use the OpenAI-compatible endpoint with your Exchange key.</p></details><details><summary>Where can I talk to a model?</summary><p><a href="https://openvibe.chat/vibe">Open Chat</a> to talk or compare models.</p></details><details><summary>Where do the numbers come from?</summary><p>Prices come from the live catalogue. Speed and reliability come from measured requests. Missing measurements stay unknown.</p></details>')}</div>`;
++}
++function modelsPage({ catalog, query = {} }) {
++ const all = listed(catalog);
++ let rows = all;
++ if (query.family && !query.maker) query = { ...query, maker: query.family };
++ const tests = {
++ maker: (m, v) => m.family === v,
++ weights: (m) => m.open_weights === true,
++ price: (m, v) =>
++ v === 'free'
++ ? best(m).price?.in === 0 && best(m).price?.out === 0
++ : best(m).price && best(m).price.in + best(m).price.out <= 2,
++ context: (m) => m.context >= 128000,
++ uncensored: (m) => m.uncensored,
++ gpu: (m) => (m.offers || []).some((o) => o.on_our_gpus === true),
++ };
++ const chip = (label, key, value, count) => {
++ const q = new URLSearchParams(
++ Object.entries(query).filter(([, v]) => typeof v === 'string'),
++ );
++ if (q.get(key) === value) q.delete(key);
++ else q.set(key, value);
++ return `<a class="ex-chip ${query[key] === value ? 'is-on' : ''}" href="/models?${esc(q.toString())}">${esc(label)} (${count})</a>`;
++ };
++ for (const [key, test] of Object.entries(tests))
++ if (query[key]) rows = rows.filter((m) => test(m, query[key]));
++ if (query.q)
++ rows = rows.filter((m) =>
++ `${m.name} ${id(m)} ${m.family}`
++ .toLowerCase()
++ .includes(String(query.q).toLowerCase()),
++ );
++ const chips = [...new Set(all.map((m) => m.family).filter(Boolean))].map(
++ (v) => chip(v, 'maker', v, all.filter((m) => tests.maker(m, v)).length),
++ );
++ for (const [key, label, value] of [
++ ['weights', 'Open weights', 'open'],
++ ['price', 'Free', 'free'],
++ ['price', 'Under $1/M blended', 'low'],
++ ['context', '128K+ context', '128k'],
++ ['uncensored', 'Uncensored 18+', '1'],
++ ['gpu', 'On our GPUs', '1'],
++ ])
++ chips.push(
++ chip(
++ label,
++ key,
++ value,
++ all.filter((m) => tests[key](m, value)).length,
++ ),
++ );
++ return `<div class="ex-wrap xp">${kit.head({ title: 'Models', sub: `${rows.length} models · live prices and measured performance` })}${unavailable(catalog)}${kit.find({ action: '/models', name: 'q', value: query.q || '', placeholder: 'Find a model', icon: 'search' })}<nav class="ex-chips" aria-label="Model filters">${chips.join('')}</nav>${table(rows)}</div>`;
++}
++module.exports = { home, modelsPage, pricing, table };
ddiff --git a/test/arena.test.js b/test/arena.test.js
iindex 15bb562..0a99dd0 100644
---- a/test/arena.test.js
++++ b/test/arena.test.js
@@@ -20,6 +20,17 @@ const { classify } = require('../server/arena/category');
});
const bench = createFakeBench();
const t = await boot({ ai, bench });
++ // Arena HTML now lives on Report; exercise the same private read through Exchange's JSON API.
++ const get = t.get;
++ t.get = async (path, options = {}) => {
++ if (/^\/arena(?:\/|$)/.test(path) && !options.form && !options.json && !options.method) {
++ const response = await get(path.replace(/^\/arena/, '/api/v1/arena/rounds'), options);
++ try { response.text = response.json().html || response.text; } catch {}
++ return response;
++ }
++ return get(path, options);
++ };
++
const alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
@@@ -69,7 +80,7 @@ const { classify } = require('../server/arena/category');
assert.ok(!r.text.includes('Qwen3 32B'), 'no model name before the vote');
assert.ok(!r.text.includes('Receipt'), 'no receipt before the vote');
assert.ok(r.text.includes('A is better') && r.text.includes('Both bad'));
-- assert.ok(r.text.includes('code'), 'the task category is said');
++ assert.match(r.text, /Code|code/, 'the task category is said');
});
await check('a vote is the person’s own Bench judgement, once', async () => {
ddiff --git a/test/nav.test.js b/test/nav.test.js
iindex a4e75c9..3e3711f 100644
---- a/test/nav.test.js
++++ b/test/nav.test.js
@@@ -10,8 +10,8 @@ assert.ok(inBar.length <= 6, `at most six places in the bar, not ${inBar.length}
assert.ok(NAV.every((l) => /^fa-[a-z0-9-]+$/.test(l.icon || '')), 'every place has an icon');
assert.ok(!NAV.some((l) => l.href === '/'), 'the brand is the way home');
assert.deepStrictEqual(new Set(NAV.map((l) => l.href)).size, NAV.length, 'no place twice');
--for (const want of ['/models', 'https://openvibe.chat/vibe', '/arena', '/start', '/api', '/why', '/own', '/sell', '/uncensored', '/ai', '/faq', '/status']) {
++for (const want of ['/models', '/search', '/pricing', '/docs', '/status', 'https://openvibe.report/ai']) {
assert.ok(NAV.some((l) => l.href === want), `${want} is reachable from the bar or More`);
}
--if (Array.isArray(PAGES)) for (const l of NAV) assert.ok(PAGES.some((p) => p.path === l.href) || ['https://openvibe.chat/vibe', '/status', '/arena', '/rankings'].includes(l.href), `${l.href} is a page`);
++assert.ok(!NAV.some(l => ['/chat','/arena','/sell','/ai'].includes(l.href)), 'moved products leave the navigation');
console.log('nav: six places in the bar with icons, the rest under More, every page reachable');
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex f350339..a4c6e18 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -12,37 +12,20 @@ const { createFakeAi, downAi } = require('./helpers/ai');
const ai = createFakeAi();
const t = await boot({ ai });
try {
-- await check('the home is the new Exchange, with live numbers', async () => {
-- const r = await t.get('/');
-- assert.strictEqual(r.status, 200, r.text);
-- assert.match(r.headers.get('cache-control'), /^public, max-age=60/);
-- assert.ok(r.text.includes('Every AI model, one account.'), 'the new promise');
-- assert.ok(!r.text.includes('Trade what people make'), 'v1’s promise is gone');
-- assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Llama 3 8B abliterated'));
-- assert.ok(r.text.includes('Popular now') && r.text.includes('Uncensored (18+)') && r.text.includes('Run on people’s machines'));
-- assert.ok(r.text.includes('models') && r.text.includes('providers') && r.text.includes('cheapest in, per million'), 'the live numbers');
-- assert.ok(r.text.includes('messages free every day'), 'the free allowance');
-- assert.ok(!r.text.includes('requests today'), 'fewer than 1,000 requests today is not shown');
-- assert.ok(r.text.includes('A side-by-side answer') && r.text.includes('example words · live prices'), 'the preview is marked an example');
-- assert.ok(r.text.includes('api.openvibe.exchange/v1/chat/completions'), 'the endpoint');
-- const lead = (r.text.match(/<p class="ex-lead">([\s\S]*?)<\/p>/) || [])[1] || '';
-- assert.ok(/DeepSeek/.test(lead) && /Qwen/.test(lead) && /Llama/.test(lead), 'the hero names the families on sale: ' + lead);
-- assert.ok(!/Kimi|GLM|MiniMax/.test(lead), 'the hero never names a family nobody sells: ' + lead);
-- const answers = [...r.text.matchAll(/<article class="ex-preview-cell">[\s\S]*?<p>([\s\S]*?)<\/p>/g)].map((m) => m[1]);
-- assert.strictEqual(answers.length, 2, 'two preview answers');
-- assert.notStrictEqual(answers[0], answers[1], 'the two example answers differ');
-- const { displayName } = require('../server/ai.js');
-- assert.deepStrictEqual(['gpt-5-nano', 'gpt-4.1-mini', 'gpt-4o-mini', 'gpt-5', 'gpt-oss-120b', 'DeepSeek V4 Pro'].map(displayName), ['GPT-5 nano', 'GPT-4.1 mini', 'GPT-4o mini', 'GPT-5', 'gpt-oss-120b', 'DeepSeek V4 Pro'], 'OpenAI models are named the way OpenAI says them');
-- assert.ok(!/ · – · –|– · –/.test(r.text), 'an unmeasured stat is left out, not printed as a dash');
-- assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
-- assert.ok(r.text.includes('<main id="main"') && r.text.includes('<footer id="ov-footer"'));
++ await check('home puts a key and live model prices first', async () => {
++ const r = await t.get('/'); assert.strictEqual(r.status,200);
++ assert.ok(r.text.includes('One API for every model.') && r.text.includes('Pay what the model costs.'));
++ assert.ok(r.text.includes('Get a key') && r.text.includes('api.openvibe.exchange/v1/chat/completions'));
++ assert.ok(r.text.includes('tokens routed this week') && r.text.includes('median time to first token'));
++ assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Try in Chat') && r.text.includes('How it ranks'));
++ assert.strictEqual((r.text.match(/<h1[\s>]/g)||[]).length,1);
});
await check('models lists every model with its cheapest offer', async () => {
const r = await t.get('/models');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('DeepSeek Chat'));
-- assert.ok(r.text.includes('$0.20 in'), 'the cheapest offer, not the dearest');
++ assert.ok(r.text.includes('$0.2 / $0.3'), 'the cheapest offer, not the dearest');
const filtered = await t.get('/models?q=llama');
assert.ok(filtered.text.includes('Llama 3 8B abliterated'));
assert.ok(!filtered.text.includes('DeepSeek Chat'), 'the search filters');
@@@ -51,6 +34,17 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(!family.text.includes('Llama 3 8B'));
});
++ await check('a model at /models/…: the same page, with first token, errors and How it ranks', async () => {
++ const r = await t.get('/models/deepseek%2Fdeepseek-chat');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('DeepSeek') && r.text.includes('DeepInfra'));
++ assert.ok(r.text.includes('First token') && r.text.includes('Errors'), 'the providers tab columns');
++ assert.ok(r.text.includes('openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat'));
++ assert.ok(r.text.includes('openvibe.report/ai/models/deepseek%2Fdeepseek-chat'), 'How it ranks');
++ assert.ok(r.text.includes('api.openvibe.exchange'));
++ assert.strictEqual((await t.get('/models/nope%2Fnope')).status, 404);
++ });
++
await check('one model: every offer, its price, speed, uptime, data policy, Try it, curl and SDK', async () => {
const r = await t.get('/m/deepseek/deepseek-chat');
assert.strictEqual(r.status, 200, r.text);
@@@ -78,17 +72,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(r.text.includes('2026-11-05') && r.text.includes('Requests'), 'usage by day');
});
-- await check('sell: the offer flow, and what is not sold, said plainly', async () => {
-- const r = await t.get('/sell');
-- assert.strictEqual(r.status, 200);
-- assert.ok(r.text.includes('Offer to the network'));
-- assert.ok(r.text.includes('Personal subscription plans'));
-- assert.ok(r.text.includes('Personal API keys'));
-- assert.ok(r.text.includes('What is sold'));
-- assert.ok(r.text.includes('Owner’s machine') || r.text.includes('No community capacity yet'));
-- assert.ok(r.text.includes('Idle-only') && r.text.includes('Kudos') && r.text.includes('sees the prompt'), 'idle-only, kudos and the privacy sentence');
-- assert.ok(r.text.includes('A community machine') && !r.text.includes('@name'), 'the seller card names the machine, never a placeholder or a person');
-- assert.ok(r.text.includes('not verified yet'), 'an offer is said verified only when it is');
++ await check('seller entry moves permanently to Link', async () => {
++ const r = await t.get('/sell'); assert.strictEqual(r.status,301);
++ assert.strictEqual(r.headers.get('location'),'https://openvibe.link');
});
await check('uncensored: sign-in first, then the 18+ gate and the floor', async () => {
@@@ -102,12 +88,9 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(r.text.includes('Non-consensual sexual content (fiction included) and incest are refused, because payment providers and hosts ban them.'));
});
-- await check('AI in OpenVibe: which product runs which model, and the daily cost', async () => {
-- const r = await t.get('/ai');
-- assert.strictEqual(r.status, 200);
-- assert.ok(r.text.includes('OpenVibe.Live'), 'the product’s full name, not “live”');
-- assert.ok(r.text.includes('deepseek-chat'));
-- assert.ok(r.text.includes('$0.0002'), 'the cost per day (0.006 over 30 days)');
++ await check('product usage moves permanently to Report', async () => {
++ const r = await t.get('/ai'); assert.strictEqual(r.status,301);
++ assert.strictEqual(r.headers.get('location'),'https://openvibe.report/ai/exchange');
});
await check('Free to try is shown only where an offer says free_tier', async () => {
@@@ -228,7 +211,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');
const down = await boot({ ai: downAi });
try {
await check('AI unreachable: every page still renders and says the numbers are not live', async () => {
-- for (const p of ['/', '/models', '/api', '/sell', '/ai', '/status']) {
++ for (const p of ['/', '/models', '/api', '/status']) {
const r = await down.get(p);
assert.strictEqual(r.status, 200, p);
assert.ok(!/NaN|undefined/.test(r.text.replace(/undefined"/g, '')), `${p} invents no number`);
ddiff --git a/test/platform-dashboard.test.js b/test/platform-dashboard.test.js
nnew file mode 100644
iindex 0000000..1a02dbd
---- /dev/null
++++ b/test/platform-dashboard.test.js
@@@ -0,0 +1,82 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++(async () => {
++ const calls = [];
++ const ai = {
++ ...createFakeAi(),
++ meRequest: async (token, path, options) => {
++ calls.push({ path, options });
++ return path.endsWith('key-usage')
++ ? {
++ rows: [
++ {
++ day: '2026-11-09',
++ key_id: 'test',
++ model: '=formula',
++ requests: 1,
++ tokens_in: 1,
++ tokens_out: 2,
++ cost_usd: 0,
++ status: 'settled',
++ },
++ ],
++ }
++ : { keys: [] };
++ },
++ };
++ const t = await boot({ ai });
++ try {
++ const user = t.network.addUser('builder');
++ await check(
++ 'dashboard needs sign in, private responses do not cache',
++ async () => {
++ assert.equal((await t.get('/dashboard')).status, 303);
++ const r = await t.get('/dashboard/keys', { as: user });
++ assert.equal(r.status, 200);
++ assert.ok(r.headers.get('cache-control').includes('no-store'));
++ assert.ok(r.text.includes('Monthly cap'));
++ },
++ );
++ await check('foreign site cannot create keys', async () => {
++ const n = calls.length;
++ assert.equal(
++ (
++ await t.get('/dashboard/keys', {
++ as: user,
++ origin: 'https://evil.example',
++ form: { name: 'Bad' },
++ })
++ ).status,
++ 403,
++ );
++ assert.equal(calls.length, n);
++ });
++ await check('CSV escapes formula cells', async () => {
++ const r = await t.get('/dashboard/usage.csv', { as: user });
++ assert.equal(r.status, 200);
++ assert.ok(r.text.includes("'=formula"));
++ });
++ await check(
++ 'Arena moves permanently and its API refuses anonymous mutations',
++ async () => {
++ assert.equal((await t.get('/arena')).status, 301);
++ assert.equal(
++ (
++ await t.get('/api/v1/arena/rounds', {
++ json: { prompt: 'hello' },
++ })
++ ).status,
++ 401,
++ );
++ },
++ );
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((e) => {
++ console.error(e);
++ process.exitCode = 1;
++});
ddiff --git a/test/pools.test.js b/test/pools.test.js
iindex a455944..0964fc9 100644
---- a/test/pools.test.js
++++ b/test/pools.test.js
@@@ -1,174 +1,40 @@
'use strict';
--/**
-- * Pooled machines, part 4 (design 2026-11-06): "Pool your machines" on /sell and the pool's offer card. The page
-- * reads the person's machines, pools, link checks and fit table from Network and AI (the mock Network here); it
-- * measures nothing itself. Every number is one that was measured; the rest says "not measured yet".
-- */
--const assert = require('assert');
--const { ids } = require('openvibe-contracts');
++// Pool management moved to Link; its old page fixtures are retained in test/retired/pools.test.js.
++const assert = require('node:assert/strict');
const { boot, check, done } = require('./helpers/boot');
const { createFakeAi } = require('./helpers/ai');
--
--const dev = () => `dev_${ids.ulid()}`;
--const poolOffer = (models) => ({
-- offer_id: 'community_abc123', kind: 'pool', label: 'Community pool (3 machines)', models,
-- pool: { machines: ['pseudonym-a', 'pseudonym-b', 'pseudonym-c'], head: 'pseudonym-a' },
-- free_only: true, verified: false, hours: { start: '00:00', end: '00:00', tz: 'UTC' }, concurrency: 1,
-- price: { in_per_million: 1, out_per_million: 2 }, audience: 'everyone', health: { status: 'up' },
-- open_now: true, ready: true,
--});
--
(async () => {
-- const ai = createFakeAi({ community: [poolOffer(['llama-3-8b-abliterated'])] });
-- const t = await boot({ ai });
-- const user = t.network.addUser('alice');
-- const desktop = dev(), laptop = dev(), rack = dev(), other = dev();
-- const announce = (id, network, freeVram, hardware) => ({ id, free_vram: freeVram, free_ram: 32000, hardware, network });
--
++ const t = await boot({ ai: createFakeAi() });
try {
-- await check('signed out, /sell has no pool card (it needs a person’s machines)', async () => {
-- const r = await t.get('/sell');
-- assert.strictEqual(r.status, 200, r.text);
-- assert.ok(!r.text.includes('Pool your machines'), 'no pool card when signed out');
-- });
--
-- await check('fewer than two machines: one line and the install link', async () => {
-- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }];
-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090')];
-- t.network.state.pools = [];
-- const r = await t.get('/sell', { as: user });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.ok(r.text.includes('Pool your machines'), 'the card is there');
-- assert.ok(r.text.includes('Pooling needs two or more of your machines on the same network. Add one with'), 'the one line');
-- assert.ok(r.text.includes('https://openvibe.link'), 'the install link');
-- assert.ok(!r.text.includes('1. Choose the machines'), 'no four steps with one machine');
-- });
--
-- await check('a machine on another network is greyed, with its reason', async () => {
-- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'elsewhere', 8000, 'RTX 3060')];
-- const r = await t.get('/sell', { as: user });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.ok(r.text.includes('Not on the same network as Desktop: pools work on one home network, not across the internet'), r.text);
-- assert.ok(/class="ex-pool-machine is-off"/.test(r.text), 'the machine is greyed, not hidden');
-- assert.ok(r.text.includes('Desktop') && r.text.includes('Laptop'), 'both machines are listed');
-- });
--
-- await check('a failed link says the number and the limit; a good one says both numbers', async () => {
-- t.network.state.machines = [
-- { id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }, { id: rack, name: 'Rack', online: true },
-- ];
-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060'), announce(rack, 'home', 16000, 'RTX 4080')];
-- t.network.state.pools = [{
-- id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',
-- machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.5 }, { id: laptop, approved: true, ready: true, vram_share: 0.17 }, { id: rack, approved: true, ready: true, vram_share: 0.33 }],
-- links: [{ machine: laptop, rtt_ms: 2, mbps: 940 }, { machine: rack, rtt_ms: 40, mbps: 940 }],
-- fit: [{ model: 'llama-3-8b-abliterated', fits: true, tokens_per_second: 9 }, { model: 'qwen3-32b', fits: true, tokens_per_second: null }],
-- }];
-- const r = await t.get('/sell', { as: user });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.ok(r.text.includes('2 ms, 940 Mb/s: good'), 'the good link in words');
-- assert.ok(r.text.includes('40 ms; a pool needs under 5 ms'), 'the failed link says the number and the limit');
-- assert.ok(r.text.includes('about 9 tokens a second'), 'a measured speed');
-- assert.ok(r.text.includes('not measured yet'), 'a model nobody measured says so');
-- assert.ok(!/about 0 tokens a second/.test(r.text), 'an absent measurement is never a zero');
-- assert.ok(r.text.includes('Head: <b>Desktop</b>'), 'the head is the machine with the most free VRAM');
-- });
--
-- await check('nothing is offered until every member approves', async () => {
-- t.network.state.pools = [{
-- id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',
-- machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.75 }, { id: laptop, approved: false, ready: false, vram_share: 0.25 }],
-- }];
-- const waiting = await t.get('/sell', { as: user });
-- assert.ok(waiting.text.includes('Waiting for approval on <b>Laptop</b>'), waiting.text);
-- assert.ok(waiting.text.includes('Approved'), 'the machine that approved says so');
-- assert.ok(waiting.text.includes('Nothing is offered until every member approves.'), 'nothing is offered yet');
-- assert.ok(!waiting.text.includes('one price for the whole pool'), 'the offer controls stay closed');
--
-- t.network.state.pools[0].machines = t.network.state.pools[0].machines.map((m) => ({ ...m, approved: true, ready: true }));
-- const approved = await t.get('/sell', { as: user });
-- assert.ok(approved.text.includes('one price for the whole pool'), 'the same controls as one machine, applied to the pool');
-- assert.ok(approved.text.includes('everyone, your followers or a group'), 'who may use it');
-- assert.ok(approved.text.includes('the whole pool pauses while any member is in use'), 'idle only');
-- assert.ok(approved.text.includes('Prompts reach your head machine (Desktop); the others only see numbers in transit.'), 'the privacy sentence');
-- assert.ok(!approved.text.includes('Nothing is offered until every member approves.'), 'it is offered now');
-- });
--
-- await check('the public card shows only the count, never a machine', async () => {
-- t.network.state.machines = [{ id: desktop, name: 'Workstation Alpha', online: true }];
-- const models = await t.get('/models');
-- assert.strictEqual(models.status, 200, models.text);
-- assert.ok(models.text.includes('Community pool (3 machines)'), 'the label');
-- assert.ok(models.text.includes('Free tier only'), 'a new pool says it serves the free tier only');
-- assert.ok(!models.text.includes('&lt;span'), 'no badge markup escapes as text');
-- assert.ok(!models.text.includes('Workstation Alpha'), 'no machine name in public');
-- const model = await t.get('/m/meta-llama/llama-3-8b-abliterated');
-- assert.ok(model.text.includes('Community pool (3 machines)'), 'the offer card carries the pool label');
-- assert.ok(!model.text.includes('Workstation Alpha'), 'still no machine name');
-- const owner = await t.get('/sell', { as: user });
-- assert.ok(owner.text.includes('Workstation Alpha'), 'the owner sees the machine names');
-- });
--
-- await check('creating the pool goes through Network, and stopping it says what happens', async () => {
-- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
-- t.network.state.pools = [];
-- const made = await t.get('/sell/pool', {
-- as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
-- body: `machines=${desktop}&machines=${laptop}&head=${desktop}`,
-- });
-- assert.strictEqual(made.status, 303, made.text);
-- assert.strictEqual(t.network.state.poolProposals.length, 1);
-- assert.deepStrictEqual(t.network.state.poolProposals[0], { machines: [desktop, laptop], head: desktop });
--
-- const one = await t.get('/sell/pool', {
-- as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `machines=${desktop}`,
-- });
-- assert.strictEqual(one.status, 200, 'one machine is refused before Network is called');
-- assert.ok(one.text.includes('Choose two or more of your machines on the same network.'));
--
-- t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: true, ready: true, vram_share: 0.4 }] }];
-- const stop = await t.get('/sell/pool/pool_TEST/stop', {
-- as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '',
-- });
-- assert.strictEqual(stop.status, 303, stop.text);
-- assert.deepStrictEqual(t.network.state.poolRevoked, ['pool_TEST']);
-- });
--
-- await check('no measurements: the page says not measured yet, never a number', async () => {
-- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
-- t.network.state.pools = [];
-- const r = await t.get('/sell', { as: user });
-- assert.ok(r.text.includes('link not measured yet'), 'an unmeasured link says so');
-- assert.ok(r.text.includes('No model has been measured on these machines yet'), 'an empty fit table says so');
-- assert.ok(!/\bMb\/s: good\b/.test(r.text), 'no invented speed');
-- });
--
-- await check('the pool card is gone for a stranger’s machines when Network cannot answer', async () => {
-- t.network.setDown(true);
-- const r = await t.get('/sell', { as: user });
-- assert.strictEqual(r.status, 200, 'the page still renders');
-- assert.ok(r.text.includes('Your machines could not be read right now. Reload in a moment.'), 'the read says why');
-- const anon = await t.get('/sell');
-- assert.ok(!anon.text.includes('Pool your machines'), 'signed out is unaffected');
-- t.network.setDown(false);
-- });
--
-- await check('phone: the four steps stack, every control is 44 px, nothing is wider than the screen', async () => {
-- const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'exchange.css'), 'utf8');
-- assert.ok(/\.ex-pool \.ex-field select, \.ex-pool \.ex-btn, \.ex-pool \.ovk-btn \{ min-height: 44px; \}/.test(css), 'controls are 44 px');
-- assert.ok(/@media \(max-width: 560px\)/.test(css), 'the phone layout is stacked');
-- t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
-- t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
-- t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: false, ready: false, vram_share: 0.4 }] }];
-- const r = await t.get('/sell', { as: user });
-- assert.strictEqual((r.text.match(/class="ex-pool-step"/g) || []).length, 4, 'four stacked step cards');
-- assert.ok(r.text.includes('class="ex-pool ex-pool-steps"'), 'the steps container');
-- });
++ const user = t.network.addUser('owner');
++ await check(
++ 'every seller page redirects permanently without exposing machine names',
++ async () => {
++ for (const path of ['/sell', '/sell/pool', '/sell/anything'])
++ for (const options of [{}, { as: user }]) {
++ const r = await t.get(path, options);
++ assert.equal(r.status, 301);
++ assert.equal(
++ r.headers.get('location'),
++ 'https://openvibe.link',
++ );
++ }
++ },
++ );
++ await check(
++ 'pool mutations still require same-origin sign-in',
++ async () => {
++ const r = await t.get('/sell/pool', {
++ as: user,
++ origin: 'https://evil.example',
++ form: { machines: 'not-a-machine' },
++ });
++ assert.equal(r.status, 403);
++ assert.equal(t.network.state.poolProposals.length, 0);
++ },
++ );
} finally {
await t.close();
}
done();
--})().catch((err) => { console.error(err); process.exitCode = 1; });
++})();
ddiff --git a/test/retired/pools.test.js b/test/retired/pools.test.js
nnew file mode 100644
iindex 0000000..2b9c516
---- /dev/null
++++ b/test/retired/pools.test.js
@@@ -0,0 +1,174 @@
++'use strict';
++/**
++ * Pooled machines, part 4 (design 2026-11-06): "Pool your machines" on /sell and the pool's offer card. The page
++ * reads the person's machines, pools, link checks and fit table from Network and AI (the mock Network here); it
++ * measures nothing itself. Every number is one that was measured; the rest says "not measured yet".
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('../helpers/boot');
++const { createFakeAi } = require('../helpers/ai');
++
++const dev = () => `dev_${ids.ulid()}`;
++const poolOffer = (models) => ({
++ offer_id: 'community_abc123', kind: 'pool', label: 'Community pool (3 machines)', models,
++ pool: { machines: ['pseudonym-a', 'pseudonym-b', 'pseudonym-c'], head: 'pseudonym-a' },
++ free_only: true, verified: false, hours: { start: '00:00', end: '00:00', tz: 'UTC' }, concurrency: 1,
++ price: { in_per_million: 1, out_per_million: 2 }, audience: 'everyone', health: { status: 'up' },
++ open_now: true, ready: true,
++});
++
++(async () => {
++ const ai = createFakeAi({ community: [poolOffer(['llama-3-8b-abliterated'])] });
++ const t = await boot({ ai });
++ const user = t.network.addUser('alice');
++ const desktop = dev(), laptop = dev(), rack = dev(), other = dev();
++ const announce = (id, network, freeVram, hardware) => ({ id, free_vram: freeVram, free_ram: 32000, hardware, network });
++
++ try {
++ await check('signed out, /sell has no pool card (it needs a person’s machines)', async () => {
++ const r = await t.get('/sell');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(!r.text.includes('Pool your machines'), 'no pool card when signed out');
++ });
++
++ await check('fewer than two machines: one line and the install link', async () => {
++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090')];
++ t.network.state.pools = [];
++ const r = await t.get('/sell', { as: user });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Pool your machines'), 'the card is there');
++ assert.ok(r.text.includes('Pooling needs two or more of your machines on the same network. Add one with'), 'the one line');
++ assert.ok(r.text.includes('https://openvibe.link'), 'the install link');
++ assert.ok(!r.text.includes('1. Choose the machines'), 'no four steps with one machine');
++ });
++
++ await check('a machine on another network is greyed, with its reason', async () => {
++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'elsewhere', 8000, 'RTX 3060')];
++ const r = await t.get('/sell', { as: user });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Not on the same network as Desktop: pools work on one home network, not across the internet'), r.text);
++ assert.ok(/class="ex-pool-machine is-off"/.test(r.text), 'the machine is greyed, not hidden');
++ assert.ok(r.text.includes('Desktop') && r.text.includes('Laptop'), 'both machines are listed');
++ });
++
++ await check('a failed link says the number and the limit; a good one says both numbers', async () => {
++ t.network.state.machines = [
++ { id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }, { id: rack, name: 'Rack', online: true },
++ ];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060'), announce(rack, 'home', 16000, 'RTX 4080')];
++ t.network.state.pools = [{
++ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',
++ machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.5 }, { id: laptop, approved: true, ready: true, vram_share: 0.17 }, { id: rack, approved: true, ready: true, vram_share: 0.33 }],
++ links: [{ machine: laptop, rtt_ms: 2, mbps: 940 }, { machine: rack, rtt_ms: 40, mbps: 940 }],
++ fit: [{ model: 'llama-3-8b-abliterated', fits: true, tokens_per_second: 9 }, { model: 'qwen3-32b', fits: true, tokens_per_second: null }],
++ }];
++ const r = await t.get('/sell', { as: user });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('2 ms, 940 Mb/s: good'), 'the good link in words');
++ assert.ok(r.text.includes('40 ms; a pool needs under 5 ms'), 'the failed link says the number and the limit');
++ assert.ok(r.text.includes('about 9 tokens a second'), 'a measured speed');
++ assert.ok(r.text.includes('not measured yet'), 'a model nobody measured says so');
++ assert.ok(!/about 0 tokens a second/.test(r.text), 'an absent measurement is never a zero');
++ assert.ok(r.text.includes('Head: <b>Desktop</b>'), 'the head is the machine with the most free VRAM');
++ });
++
++ await check('nothing is offered until every member approves', async () => {
++ t.network.state.pools = [{
++ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z',
++ machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.75 }, { id: laptop, approved: false, ready: false, vram_share: 0.25 }],
++ }];
++ const waiting = await t.get('/sell', { as: user });
++ assert.ok(waiting.text.includes('Waiting for approval on <b>Laptop</b>'), waiting.text);
++ assert.ok(waiting.text.includes('Approved'), 'the machine that approved says so');
++ assert.ok(waiting.text.includes('Nothing is offered until every member approves.'), 'nothing is offered yet');
++ assert.ok(!waiting.text.includes('one price for the whole pool'), 'the offer controls stay closed');
++
++ t.network.state.pools[0].machines = t.network.state.pools[0].machines.map((m) => ({ ...m, approved: true, ready: true }));
++ const approved = await t.get('/sell', { as: user });
++ assert.ok(approved.text.includes('one price for the whole pool'), 'the same controls as one machine, applied to the pool');
++ assert.ok(approved.text.includes('everyone, your followers or a group'), 'who may use it');
++ assert.ok(approved.text.includes('the whole pool pauses while any member is in use'), 'idle only');
++ assert.ok(approved.text.includes('Prompts reach your head machine (Desktop); the others only see numbers in transit.'), 'the privacy sentence');
++ assert.ok(!approved.text.includes('Nothing is offered until every member approves.'), 'it is offered now');
++ });
++
++ await check('the public card shows only the count, never a machine', async () => {
++ t.network.state.machines = [{ id: desktop, name: 'Workstation Alpha', online: true }];
++ const models = await t.get('/models');
++ assert.strictEqual(models.status, 200, models.text);
++ assert.ok(models.text.includes('Community pool (3 machines)'), 'the label');
++ assert.ok(models.text.includes('Free tier only'), 'a new pool says it serves the free tier only');
++ assert.ok(!models.text.includes('&lt;span'), 'no badge markup escapes as text');
++ assert.ok(!models.text.includes('Workstation Alpha'), 'no machine name in public');
++ const model = await t.get('/m/meta-llama/llama-3-8b-abliterated');
++ assert.ok(model.text.includes('Community pool (3 machines)'), 'the offer card carries the pool label');
++ assert.ok(!model.text.includes('Workstation Alpha'), 'still no machine name');
++ const owner = await t.get('/sell', { as: user });
++ assert.ok(owner.text.includes('Workstation Alpha'), 'the owner sees the machine names');
++ });
++
++ await check('creating the pool goes through Network, and stopping it says what happens', async () => {
++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
++ t.network.state.pools = [];
++ const made = await t.get('/sell/pool', {
++ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: `machines=${desktop}&machines=${laptop}&head=${desktop}`,
++ });
++ assert.strictEqual(made.status, 303, made.text);
++ assert.strictEqual(t.network.state.poolProposals.length, 1);
++ assert.deepStrictEqual(t.network.state.poolProposals[0], { machines: [desktop, laptop], head: desktop });
++
++ const one = await t.get('/sell/pool', {
++ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: `machines=${desktop}`,
++ });
++ assert.strictEqual(one.status, 200, 'one machine is refused before Network is called');
++ assert.ok(one.text.includes('Choose two or more of your machines on the same network.'));
++
++ t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: true, ready: true, vram_share: 0.4 }] }];
++ const stop = await t.get('/sell/pool/pool_TEST/stop', {
++ as: user, method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '',
++ });
++ assert.strictEqual(stop.status, 303, stop.text);
++ assert.deepStrictEqual(t.network.state.poolRevoked, ['pool_TEST']);
++ });
++
++ await check('no measurements: the page says not measured yet, never a number', async () => {
++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
++ t.network.state.pools = [];
++ const r = await t.get('/sell', { as: user });
++ assert.ok(r.text.includes('link not measured yet'), 'an unmeasured link says so');
++ assert.ok(r.text.includes('No model has been measured on these machines yet'), 'an empty fit table says so');
++ assert.ok(!/\bMb\/s: good\b/.test(r.text), 'no invented speed');
++ });
++
++ await check('the pool card is gone for a stranger’s machines when Network cannot answer', async () => {
++ t.network.setDown(true);
++ const r = await t.get('/sell', { as: user });
++ assert.strictEqual(r.status, 200, 'the page still renders');
++ assert.ok(r.text.includes('Your machines could not be read right now. Reload in a moment.'), 'the read says why');
++ const anon = await t.get('/sell');
++ assert.ok(!anon.text.includes('Pool your machines'), 'signed out is unaffected');
++ t.network.setDown(false);
++ });
++
++ await check('phone: the four steps stack, every control is 44 px, nothing is wider than the screen', async () => {
++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'exchange.css'), 'utf8');
++ assert.ok(/\.ex-pool \.ex-field select, \.ex-pool \.ex-btn, \.ex-pool \.ovk-btn \{ min-height: 44px; \}/.test(css), 'controls are 44 px');
++ assert.ok(/@media \(max-width: 560px\)/.test(css), 'the phone layout is stacked');
++ t.network.state.machines = [{ id: desktop, name: 'Desktop', online: true }, { id: laptop, name: 'Laptop', online: true }];
++ t.network.state.candidates = [announce(desktop, 'home', 24000, 'RTX 4090'), announce(laptop, 'home', 8000, 'RTX 3060')];
++ t.network.state.pools = [{ id: 'pool_TEST', head: desktop, created_at: '2026-11-07T00:00:00.000Z', machines: [{ id: desktop, approved: true, ready: true, vram_share: 0.6 }, { id: laptop, approved: false, ready: false, vram_share: 0.4 }] }];
++ const r = await t.get('/sell', { as: user });
++ assert.strictEqual((r.text.match(/class="ex-pool-step"/g) || []).length, 4, 'four stacked step cards');
++ assert.ok(r.text.includes('class="ex-pool ex-pool-steps"'), 'the steps container');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
iindex bc57401..30e83a5 100644
---- a/test/sharing.test.js
++++ b/test/sharing.test.js
@@@ -25,6 +25,17 @@ const { createFakeBench } = require('./helpers/bench');
});
const bench = createFakeBench();
const t = await boot({ ai, bench });
++ // Arena HTML now lives on Report; exercise the same private read through Exchange's JSON API.
++ const get = t.get;
++ t.get = async (path, options = {}) => {
++ if (/^\/arena(?:\/|$)/.test(path) && !options.form && !options.json && !options.method) {
++ const response = await get(path.replace(/^\/arena/, '/api/v1/arena/rounds'), options);
++ try { response.text = response.json().html || response.text; } catch {}
++ return response;
++ }
++ return get(path, options);
++ };
++
const alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
const carol = t.network.addUser('carol');
ddiff --git a/test/sso.test.js b/test/sso.test.js
iindex 4038208..c40373e 100644
---- a/test/sso.test.js
++++ b/test/sso.test.js
@@@ -104,7 +104,7 @@ const { sanitizeNext } = require('../server/auth/sso');
});
await check('public pages are complete without JavaScript', async () => {
-- for (const p of ['/', '/models', '/api', '/sell', '/uncensored', '/ai']) {
++ for (const p of ['/', '/models', '/api', '/pricing', '/search', '/uncensored']) {
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.match(r.text, /<noscript><nav/);