openvibers/openvibe.ai Commits

The human site moved to OpenVibe.Exchange; every API route stays (#346)

OP OpenVibers committed 6 days ago · 4cc7f2c01d · 10 files, +165 −432

* The human site moved to OpenVibe.Exchange; every API route stays

A browser on /, /stats, /updates, the legal pages or a Markdown twin now answers 301 on an exact page map to openvibe.exchange; an unknown human path goes to the Exchange home. The API, /runs/:id (a run receipt), /you (own provider keys), /console and /auth are untouched. The curl index at / stays and names Exchange. robots.txt disallows the old site and points at Exchange's sitemap; sitemap, llms.txt and llms-full.txt redirect. The tests pin the redirect map against the API paths that must not move.

* Name /you in the API index too

Changes

ddiff --git a/.env.example b/.env.example
iindex f7a5da9..7623de5 100644
---- a/.env.example
++++ b/.env.example
@@@ -10,6 +10,9 @@ PORT=4700
HOST=127.0.0.1
NODE_ENV=development
BASE_URL=http://localhost:4700
++# The human site: the moved public pages answer 301 here (defaults to https://openvibe.exchange). The API,
++# a run's receipt and the operator console stay on this origin.
++#OV_EXCHANGE_URL=https://openvibe.exchange
# Exact browser origins allowed to read GET /stats and /release.json (and their GET preflight).
# Defaults to https://ai.openvibe.services,https://openvibe.services; no credentials or token/console CORS.
#AI_CORS_ORIGINS=https://ai.openvibe.services,https://openvibe.services
ddiff --git a/README.md b/README.md
iindex 9b78fed..4eafd67 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,12 @@
**Status:** alpha (roadmap Wave 13). Deployed on the host since 2026-09-23 on `127.0.0.1:4700` and in
production use: OpenVibe.Live runs with `AI_SERVICE=remote` and Network's footer copy runs
`network.site_copy` here. Public since 2026-10-07 at [ai.openvibe.services](https://ai.openvibe.services):
--the home (`server/home.js`), `/stats` and the developer-app API (`ai.app.run`).
++the API (`ai.app.run`, the OpenAI-compatible `/v1`), a run's receipt (`/runs/:id`), a person's own provider
++keys (`/you`) and the operator console.
++**The public site moved to [openvibe.exchange](https://openvibe.exchange) on 2026-11-07:** the home, prices
++and latency, the changelog, the legal pages and the discovery files answer 301 there on an exact page map
++(`server/app.js`), while every API route on this origin is unchanged. The content and the map are in
++`/home/workstation/openvibe/plan-state-2026-10-22/ai-site-inventory-2026-11-07.md`.
**Domain:** `ai.openvibe.services` · **Port:** 4700 · **Unit:** `openvibe-ai.service`
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.1, §12.13, §15.14, §33, §34.
**License:** AGPL-3.0 (same as every OpenVibe service).
ddiff --git a/deploy/nginx/ai.openvibe.services.conf b/deploy/nginx/ai.openvibe.services.conf
iindex 8b94319..1a2eb93 100644
---- a/deploy/nginx/ai.openvibe.services.conf
++++ b/deploy/nginx/ai.openvibe.services.conf
@@@ -8,9 +8,10 @@
## 2026-10-10 (its last service caller stopped on 10-02).
##
## The Node service (port 4700, systemd unit openvibe-ai) serves:
--## / the product home for a browser (server/home.js), the text API index for anything else
--## /stats public price and latency per provider and model (server-rendered, no sign-in)
--## /shared/* the home's OpenVibe Frame files (content-addressed); /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt
++## / the text API index for curl and API clients; a browser is 301'd to https://openvibe.exchange/
++## /stats, /updates, /terms, /privacy, /dmca, /sitemap.xml, /llms*.txt, /index.md, /stats.md, /updates.md
++## every human page: 301 to its exact Exchange home (2026-11-07; see server/app.js's page map)
++## /shared/* the pinned OpenVibe Frame files (content-addressed); /robots.txt (disallow all, Exchange's sitemap)
## /api/health, /api/ready
## /api/v1/{chat,generate,summarize,classify,extract,embed} (POST) and /api/v1/runs/:id (GET)
## developer apps (ai.app.run, app tokens from OpenVibe.Network): open here, rate-limited
@@@ -220,11 +221,21 @@ server {
}
location = /robots.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ # The human pages: the app answers each with a 301 to its exact OpenVibe.Exchange home (server/app.js).
location = /updates { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /stats { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location = /index.md { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location = /stats.md { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location = /updates.md { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location = /terms { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location = /privacy { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location = /dmca { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /sitemap.xml { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /llms.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /llms-full.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ # A person's run receipt (OpenVibe sign-in; only their own runs): a human page that stays here because it
++ # needs the session. It is noindex, no-store, and never a redirect to Exchange.
++ location /runs/ { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; }
location = /release.json { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
# Open tabs report how their update went (openvibe-shared release-watch → POST /release-metrics, counted in /metrics).
# The catch-all below passes GET and HEAD only, so every visitor's report was refused with a 403 (2026-10-24).
ddiff --git a/server/app.js b/server/app.js
iindex 6b4597f..656190f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -16,10 +16,7 @@ const { createOps } = require('./ops');
const { consoleRouter } = require('./console');
const { createPersonSessions } = require('./person-session');
const { runPageRouter } = require('./run-pages');
--const views = require('./console/views');
--const cachePolicy = require('openvibe-shared/cache-policy');
const ovServe = require('openvibe-shared/serve');
--const home = require('./home');
const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
@@@ -86,22 +83,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// The product home's browser files (the OpenVibe Frame, openvibe-shared/serve) under content-addressed
// /shared/* URLs from this repo's pinned openvibe-shared; each answer sets its own asset cache policy.
app.use('/shared', ovServe.handler());
-- // Terms, Privacy and DMCA in this site's own words (openvibe-shared/legal, profile 'service'; 11-03: the footer's
-- // legal links answered 404 here). The facts must match the code: change both together.
-- const legal = require('openvibe-shared/legal');
-- app.get(legal.PATHS, legal.handler({
-- "host": "ai.openvibe.services",
-- "name": "AI.OpenVibe.Services",
-- "id": "ai",
-- "service": "ai",
-- "profile": "service",
-- "what": "One API for chat, generation, summaries, classification, extraction and embeddings across many models; every run says which model answered, why it was chosen and what it cost.",
-- "makes": [
-- "your runs: the model that answered, why it was chosen, its tokens and its cost",
-- "the API keys you make, and the provider keys you bring, which are stored encrypted"
-- ],
-- "ai": true
-- }));
++ // Terms, Privacy and DMCA moved with the human site: /terms, /privacy and /dmca answer 301 to
++ // OpenVibe.Exchange's own legal pages (see the page map below).
// Inline images arrive as data URLs; the per-run input cap is enforced again in runs.create.
app.use(['/api', '/v1'], express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));
@@@ -220,125 +203,38 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse, priceBook }));
app.use(runPageRouter({ runs, personSessions, log }));
-- // Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,
-- // nothing per caller. Only registered providers are listed: a person's own key records its runs as
-- // byo:<owner>:<subject>, which must never reach a public page.
-- const STATS_DAYS = 7;
-- async function statsRows() {
-- const rows = [];
-- for (const s of await quotas.statsFor({ days: STATS_DAYS })) {
-- const p = await registry.getProvider(s.provider);
-- if (!p) continue;
-- const m = s.model ? await registry.getModel(s.provider, s.model) : null;
-- rows.push({
-- provider: s.provider, model: s.model, capability: (m && m.type) || p.capabilities.join(', '),
-- requests: Number(s.requests) || 0, ok: Number(s.ok) || 0, p50: s.latency_p50_ms, p95: s.latency_p95_ms,
-- cards: await pool.rateCardsFor(s.provider, s.model || null),
-- });
-- }
-- return rows;
-- }
-- // The home's numbers and model table are the same rows, read at most once a minute (the home is cached for five).
-- let homeStats = { at: 0, rows: null };
-- async function homeRows() {
-- if (homeStats.rows && clock.now() - homeStats.at < 60_000) return homeStats.rows;
-- try { homeStats = { at: clock.now(), rows: await statsRows() }; } catch (e) { log.warn(`[home] price and latency rows unavailable: ${e.message}`); }
-- return homeStats.rows || [];
-- }
-- // The public pages, one render each: the page and its Markdown twin (SEO-1) are the same render, and the same for
-- // everyone (nothing on them is per person).
-- const publicPages = {
-- '/': async () => home.renderHome({ siteUrl: config.baseUrl, stats: { days: STATS_DAYS, rows: await homeRows() } }),
-- '/stats': async () => {
-- const now = clock.now();
-- return home.renderStats({ siteUrl: config.baseUrl, days: STATS_DAYS, rows: await statsRows(),
-- from: new Date(now - STATS_DAYS * 86400000).toISOString().slice(0, 10), to: new Date(now).toISOString().slice(0, 10) });
-- },
-- '/updates': async () => home.renderUpdates({ siteUrl: config.baseUrl }),
-- };
-- const agentLayer = require('openvibe-shared/agent-layer');
-- // The Link: header that names a public page's twin, as its head does.
-- const twinLink = (p) => agentLayer.linkHeaderTags(`${config.baseUrl}${p}`, '/llms.txt').header;
-- {
-- // A twin request (`/stats.md`, `/index.md`, ?format=markdown, Accept: text/markdown) for a public page.
-- const twins = express.Router();
-- agentLayer.mount(twins, { descriptor: require('./agent-descriptor'), render: async (p) => (publicPages[p] ? { html: await publicPages[p](), title: null, updated: null } : null) });
-- app.use((req, res, next) => {
-- const p = String(req.path || '');
-- const page = p === '/index.md' ? '/' : p.replace(/\.md$/, '');
-- const asked = p.endsWith('.md') || req.query.format === 'markdown' || req.accepts(['html', 'text/markdown']) === 'text/markdown';
-- return asked && publicPages[page] && (req.method === 'GET' || req.method === 'HEAD') ? twins(req, res, next) : next();
-- });
-- }
-- app.get('/stats', async (req, res, next) => {
-- try {
-- const html = await publicPages['/stats']();
-- res.setHeader('Cache-Control', cachePolicy.htmlHeaders());
-- res.setHeader('Content-Security-Policy', home.HOME_CSP);
-- res.setHeader('Link', twinLink('/stats'));
-- res.type('html').send(html);
-- } catch (e) { next(e); }
-- });
--
-- // Discovery for the public home (ai.openvibe.services): the home and the price page are for search engines
-- // and assistants; the console, its sign-in and the API are not.
-- app.get('/robots.txt', (_req, res) => {
-- res.type('text/plain').send(`User-agent: *\nAllow: /$\nAllow: /stats\nAllow: /updates\nDisallow: /console\nDisallow: /runs\nDisallow: /auth/\nDisallow: /api/\n\nSitemap: ${config.baseUrl}/sitemap.xml\n`);
-- });
-- app.get('/sitemap.xml', async (_req, res, next) => {
-- try {
-- const from = new Date(clock.now() - STATS_DAYS * 86400000).toISOString().slice(0, 10);
-- const stats = await db.prepare('SELECT MAX(day) AS lastmod FROM provider_stats_daily WHERE day >= ?').get(from);
-- const statsLastmod = [stats && stats.lastmod ? new Date(stats.lastmod).toISOString() : null, releaseLastmod].filter(Boolean).sort().pop();
-- // The paths come from home.PUBLIC_PAGES, the same catalogue /llms-full.txt describes.
-- const lastmods = { '/': releaseLastmod, '/stats': statsLastmod, '/updates': releaseLastmod };
-- const entries = home.PUBLIC_PAGES.map((p) => [p.path, lastmods[p.path]]);
-- res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map(([p, lastmod]) => ` <url><loc>${config.baseUrl}${p}</loc>${lastmod ? `<lastmod>${lastmod.slice(0, 10)}</lastmod>` : ''}</url>`).join('\n')}\n</urlset>\n`);
-- } catch (e) { next(e); }
-- });
-- app.get('/llms.txt', (_req, res) => {
-- res.setHeader('Cache-Control', 'public, max-age=3600');
-- res.type('text/plain').send(home.llmsTxt({ siteUrl: config.baseUrl }));
-- });
-- // The full AI map: the same page catalogue as the sitemap plus the developer API, in plain text.
-- app.get('/llms-full.txt', (_req, res) => {
-- res.setHeader('Cache-Control', 'public, max-age=3600');
-- res.type('text/plain').send(home.llmsFullTxt({ siteUrl: config.baseUrl }));
-- });
--
-- // GET /: the home page for a browser (server/home.js), the API index for curl and API clients (`*/*`, no
-- // Accept, or any non-HTML Accept). Vary: Accept so a shared cache never serves one to the other; the home is
-- // private because Cloudflare caches by URL and ignores Vary: Accept. The home carries its own CSP.
-- // GET /updates: what shipped on this site (the shared update log).
-- app.get('/updates', (req, res) => {
-- res.type('html').set('Content-Security-Policy', home.HOME_CSP).set('Cache-Control', 'public, max-age=300')
-- .set('Link', twinLink('/updates')).send(home.renderUpdates({ siteUrl: config.baseUrl }));
-- });
--
-- app.get('/', async (req, res) => {
++ // ── The public pages moved to OpenVibe.Exchange (2026-11-07) ────
++ // ai.openvibe.services keeps the API, a run's receipt and the operator console. Every human page it used
++ // to serve answers 301 to its Exchange home: the page map is exact, an unknown human path goes to the home,
++ // and no API path is ever redirected. The site's page list, price page, update log, legal pages and
++ // crawl artifacts moved with it.
++ const EXCHANGE = config.exchangeUrl;
++ const moved = (to) => (_req, res) => res.redirect(301, `${EXCHANGE}${to}`);
++ app.get('/', (req, res) => {
res.vary('Accept');
-- if (req.accepts(['text/plain', 'text/html']) === 'text/html') {
-- const html = await publicPages['/']();
-- return res.type('html')
-- .set('Content-Security-Policy', home.HOME_CSP)
-- .set('Cache-Control', 'private, max-age=300')
-- .set('Link', twinLink('/'))
-- .send(html);
++ const html = req.accepts(['text/plain', 'text/html']) === 'text/html';
++ const markdown = req.query.format === 'markdown' || /text\/markdown/i.test(String(req.get('accept') || ''));
++ if (html || markdown) {
++ res.set('Cache-Control', 'public, max-age=300');
++ return res.redirect(301, markdown ? `${EXCHANGE}/index.md` : `${EXCHANGE}/`);
}
res.type('text/plain').send([
'OpenVibe.AI: providers, models, routing, versioned prompt templates and workflows, runs, citations, scoped cache, quotas and audit.',
'',
++ 'The public pages (home, prices and latency, the changelog, the FAQ) are OpenVibe.Exchange now:',
++ `${EXCHANGE}/`,
++ '',
'POST /api/v1/runs {workflow, input, target?, idempotency_key} run a workflow (?wait=ms)',
'GET /api/v1/runs/:id POST /api/v1/runs/:id/cancel|retry',
'POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
'GET /api/health, /api/ready, /release.json',
-- 'GET /stats price and latency per provider and model (public)',
'GET /runs/:id your run receipt (OpenVibe.Network sign-in; your own runs only)',
-- 'GET /console the operator console (OpenVibe.Network staff sign-in)',
++ 'GET /you your own provider keys and how your AI is paid (OpenVibe.Network sign-in)',
++ 'GET /console the operator console (OpenVibe.Network staff sign-in)',
'',
'Callers authenticate with OpenVibe.Network tokens (audience openvibe.ai): first-party service tokens, or a',
-- 'developer app\'s token (ai.app.run) on the six direct operations and GET /api/v1/runs/:id.',
++ "developer app's token (ai.app.run) on the six direct operations and GET /api/v1/runs/:id.",
'Developer apps: https://openvibe.services/projects',
'AI output is a draft/evidence package attributed to a workflow, model and run, never to a person.',
'',
@@@ -346,15 +242,26 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
'',
].join('\n'));
});
++ // The exact page map. A Markdown twin points at its Exchange twin, not at the HTML page.
++ app.get('/stats', moved('/models'));
++ app.get('/updates', moved('/status'));
++ app.get('/index.md', moved('/index.md'));
++ app.get('/stats.md', moved('/models.md'));
++ app.get('/updates.md', moved('/status.md'));
++ for (const p of ['/terms', '/privacy', '/dmca', '/sitemap.xml', '/llms.txt', '/llms-full.txt']) app.get(p, moved(p));
-- // Browsers ask for /favicon.ico on their own: the app icon, as SVG.
-- app.get('/favicon.ico', (req, res) => res.type('image/svg+xml').set('Cache-Control', 'public, max-age=86400').send(require('openvibe-shared/app-icon').favicon({ site: 'ai' })));
++ // Discovery: the human site is Exchange, so this origin asks crawlers to leave it alone and names the map
++ // that matters. Machine routes (the API, release.json, a run's receipt) are not crawled anyway.
++ app.get('/robots.txt', (_req, res) => {
++ res.type('text/plain').send(`User-agent: *\nDisallow: /\nDisallow: /runs\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n\n# The human site is OpenVibe.Exchange; the API stays here.\nSitemap: ${EXCHANGE}/sitemap.xml\n`);
++ });
-- // A browser asking for a page that is not here gets a page; an API client the problem document.
++ // A browser asking for a page that is not here goes to the Exchange home: the human site moved, and a
++ // stale link should land on the product, not a 404. An API or session path still answers as it did.
++ const MACHINE_PATH = /^\/(?:api|v1|internal|metrics|shared|release\.json|release-metrics|auth|console|runs|favicon\.ico|robots\.txt)(?:\/|$)/;
app.use((req, res) => {
-- if (req.method === 'GET' && !req.path.startsWith('/api/') && !req.path.startsWith('/v1/') && req.accepts(['application/json', 'text/html']) === 'text/html') {
-- return res.status(404).type('html').set('Content-Security-Policy', home.HOME_CSP).set('Cache-Control', 'no-store')
-- .set('X-Robots-Tag', 'noindex').send(home.renderNotFound({ siteUrl: config.baseUrl }));
++ if (req.method === 'GET' && !MACHINE_PATH.test(req.path) && req.accepts(['application/json', 'text/html']) === 'text/html') {
++ return res.redirect(301, `${EXCHANGE}/`);
}
return http.sendProblem(res, 404, 'ai.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov });
});
ddiff --git a/server/config.js b/server/config.js
iindex cda9dd7..6ea673a 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -48,6 +48,9 @@ function load(env = process.env) {
nodeEnv,
isProduction,
baseUrl,
++ // The human pages that used to live here are OpenVibe.Exchange now (2026-11-07). This is where a
++ // browser is sent, page by page; the API, a run's receipt and the operator console stay here.
++ exchangeUrl: trimUrl(env.OV_EXCHANGE_URL || 'https://openvibe.exchange'),
// Browser reads of the public stats and release endpoints only. Values are exact origins;
// CORS never allows credentials, and token/console routes do not use this list.
cors: { origins: list(env.AI_CORS_ORIGINS, ['https://ai.openvibe.services', 'https://openvibe.services']) },
ddiff --git a/test/agent-twins.test.js b/test/agent-twins.test.js
iindex f98dbf3..dc5a22f 100644
---- a/test/agent-twins.test.js
++++ b/test/agent-twins.test.js
@@@ -1,41 +1,40 @@
'use strict';
--// SEO-1: the public pages' Markdown twins. A twin is the page's own render (the same for everyone: nothing on these
--// pages is per person), and the page names it in its head and in the Link: header.
++// SEO-1 moved with the public pages: ai.openvibe.services serves no Markdown twins any more. Each old twin path
++// answers 301 to its Exchange twin, and an Accept: text/markdown request for the home does the same. The API,
++// the console and the run receipt are not answered as Markdown.
const assert = require('assert');
const { boot, request, suite } = require('./helpers');
--const t = suite('agent-twins');
++const t = suite('agent-twins-moved');
let h;
--const HTML = { accept: 'text/html' };
++const EXCHANGE = 'https://openvibe.exchange';
++const location = (r) => String(r.headers.get('location') || '');
t.test('boot', async () => {
h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
});
--t.test('each public page names its twin in the head and the Link header, and the twin is the page', async () => {
-- for (const [p, twinPath, words] of [['/', '/index.md', 'OpenVibe.AI'], ['/stats', '/stats.md', 'no provider calls in the last 7 days'], ['/updates', '/updates.md', 'OpenVibe.AI']]) {
-- const page = await request(h.base, 'GET', p, { headers: HTML });
-- assert.strictEqual(page.status, 200, p);
-- const head = page.text.match(/<link rel="alternate" type="text\/markdown" href="([^"]+)">/);
-- assert.ok(head && head[1].endsWith(twinPath), `${p} names ${twinPath} in the head`);
-- assert.ok(page.text.includes('<link rel="describedby" href="/llms.txt">'), `${p} names llms.txt`);
-- const link = String(page.headers.get('link') || '');
-- assert.ok(link.includes(`<${head[1]}>; rel="alternate"; type="text/markdown"`) && link.includes('</llms.txt>; rel="describedby"'), `${p}: the Link header says what the head says (${link})`);
-- const twin = await request(h.base, 'GET', twinPath);
-- assert.strictEqual(twin.status, 200, twinPath);
-- assert.match(String(twin.headers.get('content-type')), /^text\/markdown/, `${twinPath} is Markdown`);
-- assert.ok(twin.text.startsWith('---\ntitle:') && twin.text.includes(words), `${twinPath} is the page's words`);
-- assert.ok(!/<(?:script|style)\b/i.test(twin.text.replace(/~~~~[\s\S]*?~~~~/g, '')), `${twinPath} carries no markup`);
-- const asked = await request(h.base, 'GET', p, { headers: { accept: 'text/markdown' } });
-- assert.match(String(asked.headers.get('content-type')), /^text\/markdown/, `${p} with Accept: text/markdown is the twin`);
++t.test('every old twin path is a 301 to its Exchange twin', async () => {
++ for (const [p, to] of [['/index.md', '/index.md'], ['/stats.md', '/models.md'], ['/updates.md', '/status.md']]) {
++ const r = await request(h.base, 'GET', p, { redirect: 'manual' });
++ assert.strictEqual(r.status, 301, p);
++ assert.strictEqual(location(r), `${EXCHANGE}${to}`, p);
++ assert.ok(!/^text\/markdown/.test(String(r.headers.get('content-type') || '')), p);
}
});
--t.test('nothing else is a twin: the console, the API, and the API index for curl are as they were', async () => {
-- const console_ = await request(h.base, 'GET', '/console.md');
-- assert.ok(!/^text\/markdown/.test(String(console_.headers.get('content-type'))), 'the console has no twin');
-- const api = await request(h.base, 'GET', '/api/v1/workflows', { headers: { accept: 'text/markdown' } });
-- assert.ok(!/^text\/markdown/.test(String(api.headers.get('content-type'))), 'the API is not answered as a twin');
++t.test('the home with Accept: text/markdown goes to the Exchange twin', async () => {
++ const r = await request(h.base, 'GET', '/', { headers: { accept: 'text/markdown' }, redirect: 'manual' });
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(location(r), `${EXCHANGE}/index.md`);
++});
++
++t.test('nothing else is a twin now: the console, the API and the curl index are as they were', async () => {
++ const console_ = await request(h.base, 'GET', '/console.md', { redirect: 'manual' });
++ assert.notStrictEqual(location(console_), `${EXCHANGE}/console.md`, 'the console is not moved as a twin');
++ const api = await request(h.base, 'GET', '/api/v1/workflows', { headers: { accept: 'text/markdown' }, redirect: 'manual' });
++ assert.ok(!/^text\/markdown/.test(String(api.headers.get('content-type') || '')), 'the API is not answered as a twin');
++ assert.ok(!location(api).startsWith(EXCHANGE), 'an API path is never redirected');
const index = await request(h.base, 'GET', '/');
assert.match(String(index.headers.get('content-type')), /^text\/plain/, 'curl still gets the API index');
});
ddiff --git a/test/cors.test.js b/test/cors.test.js
iindex 0c20d23..a89af26 100644
---- a/test/cors.test.js
++++ b/test/cors.test.js
@@@ -22,9 +22,9 @@ t.test('config has exact public origins and accepts an override', () => {
t.test('boot', async () => { h = await boot(); });
t.test('listed origins can read only the two public GETs without credentials', async () => {
-- for (const [path, origin] of [['/stats', allowed], ['/release.json', otherAllowed]]) {
-- const r = await request(h.base, 'GET', path, { headers: { Origin: origin } });
-- assert.strictEqual(r.status, 200, r.text);
++ for (const [path, origin, status] of [['/stats', allowed, 301], ['/release.json', otherAllowed, 200]]) {
++ const r = await request(h.base, 'GET', path, { headers: { Origin: origin }, redirect: 'manual' });
++ assert.strictEqual(r.status, status, r.text);
assert.strictEqual(r.headers.get('access-control-allow-origin'), origin);
assert.match(r.headers.get('vary'), /\bOrigin\b/i);
assert.strictEqual(r.headers.get('access-control-allow-credentials'), null);
@@@ -33,8 +33,8 @@ t.test('listed origins can read only the two public GETs without credentials', a
t.test('unlisted and merely similar origins receive no CORS permission', async () => {
for (const origin of [rejected, `${allowed}:443`, 'null']) {
-- const r = await request(h.base, 'GET', '/stats', { headers: { Origin: origin } });
-- assert.strictEqual(r.status, 200, r.text);
++ const r = await request(h.base, 'GET', '/stats', { headers: { Origin: origin }, redirect: 'manual' });
++ assert.strictEqual(r.status, 301, r.text);
assertNoCors(r.headers);
}
const r = await request(h.base, 'GET', '/release.json', { headers: { Origin: rejected } });
ddiff --git a/test/helpers.js b/test/helpers.js
iindex fec6675..d8bfe49 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -67,11 +67,11 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
return { ...h, base, dir, env: fullEnv, testdb, stop: async () => { await h.close(); if (!reuse) await testdb.close(); } };
}
--async function request(base, method, p, { tok, body, headers = {} } = {}) {
++async function request(base, method, p, { tok, body, headers = {}, redirect = 'follow' } = {}) {
const h = { ...headers };
if (tok) h.Authorization = `Bearer ${tok}`;
if (body !== undefined) h['Content-Type'] = 'application/json';
-- const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });
++ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined, redirect });
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
ddiff --git a/test/home.test.js b/test/home.test.js
iindex fc0ca88..7f0c443 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -1,229 +1,108 @@
'use strict';
--// The public home (plan T6: AI as a developer product). ai.openvibe.services renders server/home.js for a
--// browser and keeps the text/plain API index for curl and API clients, serves the pinned OpenVibe Frame at
--// /shared, and answers robots.txt, sitemap.xml and llms.txt. The vhosts: the developer-app routes are the only
--// API the public vhost proxies, and the old ai.openvibe.network address is retired (no vhost of its own).
++// The public pages of ai.openvibe.services moved to openvibe.exchange on 2026-11-07. This origin keeps the API,
++// a run's receipt and the operator console; every human page answers 301 on an exact map (an unknown human path
++// goes to the Exchange home), and no API path is ever redirected. The text/plain API index for curl stays.
const assert = require('assert');
--
--function assertSitemapLastmods(xml, tag = 'url') {
-- const entries = [...xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'g'))];
-- for (const [, body] of entries) {
-- const match = /<lastmod>(\d{4}-\d{2}-\d{2})<\/lastmod>/.exec(body);
-- assert.ok(match, body);
-- assert.strictEqual(new Date(match[1]).toISOString().slice(0, 10), match[1]);
-- }
-- return entries;
--}
--
const fs = require('fs');
const path = require('path');
const { boot, request, suite } = require('./helpers');
--const { HOME_CSP, renderHome, FAQ, DESCRIPTION } = require('../server/home');
const SITE = 'https://ai.openvibe.services';
++const EXCHANGE = 'https://openvibe.exchange';
const BROWSER = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
--const t = suite('home');
++const t = suite('home-redirects');
let h;
++const location = (r) => String(r.headers.get('location') || '');
++const moved = async (p, to, headers = BROWSER) => {
++ const r = await request(h.base, 'GET', p, { headers: { Accept: headers }, redirect: 'manual' });
++ assert.strictEqual(r.status, 301, `${p}: ${r.status} ${r.text.slice(0, 120)}`);
++ assert.strictEqual(location(r), `${EXCHANGE}${to}`, p);
++ return r;
++};
++
t.test('boot with the public origin as BASE_URL', async () => {
h = await boot({ env: { BASE_URL: SITE } });
});
--t.test('GET / as a browser: the home page, its CSP and a private cache', async () => {
-- const r = await request(h.base, 'GET', '/', { headers: { Accept: BROWSER } });
-- assert.strictEqual(r.status, 200);
-- assert.match(r.headers.get('content-type'), /^text\/html/);
-- assert.strictEqual(r.headers.get('content-security-policy'), HOME_CSP);
-- assert.strictEqual(r.headers.get('cache-control'), 'private, max-age=300', 'Cloudflare ignores Vary: Accept');
-- assert.match(r.headers.get('vary'), /Accept/);
-- assert.strictEqual((r.text.match(/<h1\b/g) || []).length, 1, 'exactly one h1');
-- assert.ok(r.text.includes('Many models, one API.'), 'the hero');
-- assert.ok(r.text.includes('<div class="sc-grid" role="region" aria-label="What you get" tabindex="0">'), 'the sideways rail is reachable by keyboard');
-- assert.ok(r.text.includes('point it at /v1 for Chat Completions with tools'), 'the OpenAI-compatible endpoint is on the home');
-- assert.ok(r.text.includes('Your project&#39;s own provider') || r.text.includes("Your project's own provider"), 'the project\'s own provider row');
-- assert.ok(!r.text.includes('metered models under a project budget'), 'production app runs are free-only until project budgets open');
-- assert.ok(r.text.includes(`<link rel="canonical" href="${SITE}/">`), 'the canonical public origin');
-- assert.ok(r.text.includes(`${SITE}/api/v1/chat`), 'the examples call the public origin');
-- assert.ok(r.text.includes('https://openvibe.services/projects'), 'where a project and an app are made');
-- assert.ok(!/ai\.openvibe\.network/.test(r.text), 'never the old name');
--});
--
--t.test('the home: numbers and the model table come from the /stats rows; nothing is shown without them', async () => {
-- const card = (i, o) => ({ in: { unit_price_usd: i, unit_size: 1e6 }, cached: { unit_price_usd: i / 10, unit_size: 1e6 }, out: { unit_price_usd: o, unit_size: 1e6 } });
-- const rows = [
-- { provider: 'local', model: 'qwen2.5-1.5b-instruct', capability: 'chat, generate', requests: 10, ok: 10, p50: 25600, p95: 25600, cards: null },
-- { provider: 'shared', model: 'gpt-5-nano', capability: 'chat, generate, summarize, classify, extract, enrich, json', requests: 1003, ok: 1000, p50: 3200, p95: 25600, cards: card(0.05, 0.4) },
-- { provider: 'shared', model: 'never-answered', capability: 'chat', requests: 3, ok: 0, p50: 25600, p95: 25600, cards: card(0.25, 2) },
-- ];
-- const html = renderHome({ siteUrl: SITE, stats: { days: 7, rows } });
-- assert.match(html, /<b>1,010<\/b><span>calls answered in the last 7 days<\/span>/);
-- assert.match(html, /<b>2<\/b><span>models answered them<\/span>/);
-- // On a phone each model is a card: every cell carries its column's name (2026-11-02: the table scrolled sideways
-- // with rows a screen tall).
-- assert.ok(/<td data-label="Provider">[\s\S]*?<td data-label="Price">/.test(html), 'each cell names its column');
-- assert.ok(/@media \(max-width:600px\)\{#models[^\n]*table\.sc-table\{min-width:0;/.test(html), 'and the phone layout undoes the table width');
-- assert.match(html, /<b>99\.4%<\/b><span>of provider calls succeeded<\/span>/);
-- assert.ok(html.indexOf('gpt-5-nano') < html.indexOf('qwen2.5-1.5b-instruct'), 'most used first');
-- assert.ok(!html.includes('never-answered'), 'a model with no answer is left out');
-- assert.ok(html.includes('$0.05 in · $0.40 out per 1M tokens') && html.includes('not billed'), 'prices from the rate cards');
-- assert.ok(html.includes('under 3.2 s'), 'latency as the bucket bound');
-- assert.ok(html.includes('chat, generate, summarize, classify, extract and 2 more'), 'long capability lists are cut');
-- assert.ok(html.includes('a small local model, without tools'), 'what an app gets on the router, as the README says');
-- const empty = renderHome({ siteUrl: SITE, stats: { days: 7, rows: [] } });
-- assert.ok(!/calls answered|id="models"/.test(empty), 'no rows: no numbers and no table');
--});
--
--t.test('the home: OpenAI SDK samples, the FAQ and its JSON-LD, a description of at most 160 characters', async () => {
-- const html = renderHome({ siteUrl: SITE, stats: { days: 7, rows: [] } });
-- assert.ok(html.includes(`base_url=&quot;${SITE}/v1&quot;`) || html.includes(`base_url="${SITE}/v1"`), 'the Python sample points at /v1');
-- assert.ok(html.includes(`baseURL: &#39;${SITE}/v1&#39;`) || html.includes(`baseURL: '${SITE}/v1'`), 'the Node sample points at /v1');
-- assert.ok(DESCRIPTION.length <= 160, `description ${DESCRIPTION.length} chars`);
-- assert.ok(html.includes(`<meta name="description" content="${DESCRIPTION}">`), 'the whole description, not clipped');
-- const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
-- const types = blocks.map((b) => b['@type']);
-- for (const t of ['WebSite', 'WebApplication', 'FAQPage']) assert.ok(types.includes(t), `JSON-LD ${t}: ${types}`);
-- const faq = blocks.find((b) => b['@type'] === 'FAQPage');
-- assert.strictEqual(faq.mainEntity.length, FAQ.length);
-- faq.mainEntity.forEach((q) => assert.ok(html.includes(`<h3>${q.name.replace(/'/g, '&#39;')}</h3>`), `the page shows "${q.name}"`));
-- assert.ok(!/OpenVibe\.Codes/.test(html), 'apps and the API reference are on OpenVibe.Services, never named as Codes');
--});
--
--t.test('GET / for curl and API clients: the text index, unchanged in kind', async () => {
++t.test('the home: a browser is sent to Exchange, curl still gets the API index', async () => {
++ const r = await moved('/', '/');
++ assert.match(r.headers.get('vary'), /\bAccept\b/i);
++ assert.match(r.headers.get('cache-control'), /^public, max-age=300$/);
for (const accept of [undefined, '*/*', 'application/json', 'text/plain']) {
-- const r = await request(h.base, 'GET', '/', { headers: accept ? { Accept: accept } : {} });
-- assert.strictEqual(r.status, 200, String(accept));
-- assert.match(r.headers.get('content-type'), /^text\/plain/, String(accept));
-- assert.ok(r.text.startsWith('OpenVibe.AI: providers, models, routing'), String(accept));
-- assert.ok(r.text.includes('ai.app.run') && r.text.includes('/console'), String(accept));
++ const text = await request(h.base, 'GET', '/', { headers: accept ? { Accept: accept } : {} });
++ assert.strictEqual(text.status, 200, String(accept));
++ assert.match(text.headers.get('content-type'), /^text\/plain/, String(accept));
++ assert.ok(text.text.startsWith('OpenVibe.AI: providers, models, routing'), String(accept));
++ assert.ok(text.text.includes('POST /api/v1/runs') && text.text.includes('GET /runs/:id') && text.text.includes('GET /console'), String(accept));
++ assert.ok(text.text.includes(`${EXCHANGE}/`), 'the text index names the new home');
}
++ await moved('/?format=markdown', '/index.md');
++ const md = await request(h.base, 'GET', '/', { headers: { Accept: 'text/markdown' }, redirect: 'manual' });
++ assert.strictEqual(md.status, 301);
++ assert.strictEqual(location(md), `${EXCHANGE}/index.md`);
});
--t.test('the home links the pinned Frame stylesheet, and /shared serves it', async () => {
-- const r = await request(h.base, 'GET', '/', { headers: { Accept: BROWSER } });
-- const href = (r.text.match(/href="(\/shared\/showcase\.css\?v=[0-9a-f]+)"/) || [])[1];
-- assert.ok(href, 'the showcase stylesheet, content-addressed');
-- const css = await request(h.base, 'GET', href);
-- assert.strictEqual(css.status, 200);
-- assert.match(css.headers.get('content-type'), /text\/css/);
-- assert.match(css.headers.get('cache-control'), /immutable/);
++t.test('the exact page map, including the Markdown twins and the legal pages', async () => {
++ for (const [p, to] of [
++ ['/stats', '/models'],
++ ['/updates', '/status'],
++ ['/index.md', '/index.md'],
++ ['/stats.md', '/models.md'],
++ ['/updates.md', '/status.md'],
++ ['/terms', '/terms'],
++ ['/privacy', '/privacy'],
++ ['/dmca', '/dmca'],
++ ['/sitemap.xml', '/sitemap.xml'],
++ ['/llms.txt', '/llms.txt'],
++ ['/llms-full.txt', '/llms-full.txt'],
++ ]) await moved(p, to);
});
--t.test('/updates: what shipped on this site, the shared update log, with one heading and the footer link', async () => {
-- const r = await request(h.base, 'GET', '/updates', { headers: { Accept: 'text/html' } });
-- assert.strictEqual(r.status, 200);
-- assert.match(r.headers.get('content-type'), /^text\/html/);
-- assert.ok(r.text.includes('<h1>What shipped on OpenVibe.AI</h1>') && r.text.includes('data-ov-shipped="log" data-service="ai"'));
-- assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1);
-- assert.match(r.text, /<script src="\/shared\/shipped\.js\?v=[0-9a-f]+" defer><\/script>/);
-- assert.match(r.text, new RegExp(`<link rel="canonical" href="${SITE.replace(/[.]/g, '\\.')}/updates">`));
-- assert.ok(r.headers.get('content-security-policy').includes("connect-src 'self' https://openvibe.network"), 'the log reads the network changelog');
++t.test('an unknown human page goes to the Exchange home; an unknown API path stays a problem document', async () => {
++ await moved('/prices-and-latency', '/');
++ await moved('/some-old-post', '/');
++ for (const p of ['/api/nope', '/v1/nope', '/api/v1/nope']) {
++ const r = await request(h.base, 'GET', p, { headers: { Accept: BROWSER }, redirect: 'manual' });
++ assert.strictEqual(r.status, 404, p);
++ assert.match(r.headers.get('content-type') || '', /application\/problem\+json/, p);
++ assert.ok(!location(r).startsWith(EXCHANGE), p);
++ }
});
--t.test('/stats (price and latency) is in search: index,follow, its own description and canonical', async () => {
-- const r = await request(h.base, 'GET', '/stats');
-- assert.strictEqual(r.status, 200);
-- assert.ok(r.text.includes('<meta name="robots" content="index,follow">'), 'indexable');
-- assert.match(r.text, /<meta name="description" content="Price and latency of every AI model OpenVibe\.AI called in the last 7 days: [^"]+">/);
-- assert.ok(r.text.includes(`<link rel="canonical" href="${SITE}/stats">`), 'canonical');
-- assert.ok(r.text.includes(`<meta property="og:url" content="${SITE}/stats">`), 'a shared link has its tags');
-- assert.match(r.text, new RegExp(`<meta property="og:image" content="${SITE.replace(/\./g, '\\.')}/shared/social/ai\\.jpg\\?v=[0-9a-f]+">`));
-- assert.ok(r.text.includes('<meta name="twitter:card" content="summary_large_image">'));
++t.test('the machine and session routes are not redirected', async () => {
++ const health = await request(h.base, 'GET', '/api/health');
++ assert.strictEqual(health.status, 200);
++ assert.strictEqual(health.body.status, 'ok');
++ const release = await request(h.base, 'GET', '/release.json');
++ assert.strictEqual(release.status, 200);
++ for (const p of ['/console', '/runs/run_01J0000000000000000000000', '/auth/login']) {
++ const r = await request(h.base, 'GET', p, { headers: { Accept: BROWSER }, redirect: 'manual' });
++ assert.ok(!location(r).startsWith(EXCHANGE), `${p} stays on this origin (${r.status} ${location(r)})`);
++ }
});
--t.test('robots.txt, sitemap.xml and llms.txt name the public origin', async () => {
-- const robots = await request(h.base, 'GET', '/robots.txt');
-- assert.strictEqual(robots.status, 200);
-- for (const line of ['Allow: /$', 'Allow: /stats', 'Allow: /updates', 'Disallow: /console', 'Disallow: /auth/', 'Disallow: /api/', `Sitemap: ${SITE}/sitemap.xml`]) assert.ok(robots.text.includes(line), line);
-- const map = await request(h.base, 'GET', '/sitemap.xml');
-- assert.strictEqual(map.status, 200);
-- assert.match(map.headers.get('content-type'), /xml/);
-- assert.deepStrictEqual([...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]), [`${SITE}/`, `${SITE}/stats`, `${SITE}/updates`]);
-- assert.strictEqual(assertSitemapLastmods(map.text).length, 3);
-- const llms = await request(h.base, 'GET', '/llms.txt');
-- assert.strictEqual(llms.status, 200);
-- assert.match(llms.headers.get('content-type'), /^text\/plain/);
-- assert.ok(llms.text.startsWith('# OpenVibe.AI'));
-- assert.ok(llms.text.includes(`${SITE}/stats`) && llms.text.includes(`POST ${SITE}/api/v1/chat`));
-- // AGT-5: the pages are links, each of this site's own at its Markdown twin; other sites keep theirs; Optional is last.
-- assert.ok(llms.text.includes(`[Home](${SITE}/index.md)`) && llms.text.includes(`(${SITE}/stats.md)`), llms.text.slice(0, 600));
-- assert.ok(llms.text.includes('(https://openvibe.services/docs/api)') && llms.text.includes(`(${SITE}/sitemap.xml)`));
-- assert.ok(llms.text.includes('\n## Optional\n') && llms.text.lastIndexOf('\n## ') === llms.text.indexOf('\n## Optional\n'), 'Optional is last');
-- const full = await request(h.base, 'GET', '/llms-full.txt');
-- assert.strictEqual(full.status, 200);
-- assert.match(full.headers.get('content-type'), /^text\/plain/);
-- assert.ok(full.text.startsWith('# OpenVibe.AI'));
-- assert.ok(full.text.length >= 500, `real content, not a stub (${full.text.length} characters)`);
-- for (const loc of [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1])) assert.ok(full.text.includes(loc), `llms-full.txt names ${loc}`);
-- assert.ok(full.text.includes('OpenAPI reference'), 'the generated API reference is named');
++t.test('robots.txt asks crawlers to leave this origin alone and names Exchange’s sitemap', async () => {
++ const r = await request(h.base, 'GET', '/robots.txt');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Disallow: /'));
++ assert.ok(r.text.includes(`Sitemap: ${EXCHANGE}/sitemap.xml`));
});
const active = (file) => fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', file), 'utf8').split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
--t.test('the public vhost proxies the home, its files and exactly the developer-app API routes', () => {
++t.test('the vhost still proxies the API, the run receipt and the console; the app answers and redirects the pages', () => {
const conf = active('ai.openvibe.services.conf');
-- for (const loc of ['location = / {', 'location = /stats', 'location = /updates', 'location = /sitemap.xml', 'location = /llms.txt', 'location = /llms-full.txt', 'location /shared/']) assert.ok(conf.includes(loc), loc);
++ for (const loc of ['location = /stats', 'location = /updates', 'location = /terms', 'location = /privacy', 'location = /dmca', 'location = /sitemap.xml', 'location = /llms.txt', 'location /runs/ {', 'location /console {', 'location /auth/ {', 'location /shared/']) assert.ok(conf.includes(loc), loc);
const ops = conf.indexOf('location ~ ^/api/v1/(chat|generate|summarize|classify|extract|embed)$ {');
const run = conf.indexOf('location ~ ^/api/v1/runs/[A-Za-z0-9_-]+$ {');
const completions = conf.indexOf('location = /v1/chat/completions {');
const models = conf.indexOf('location = /v1/models {');
-- assert.ok(ops >= 0 && run >= 0, 'the six operations and the run read');
-- assert.ok(completions >= 0 && models >= 0, 'the OpenAI-compatible endpoint and its model list');
++ assert.ok(ops >= 0 && run >= 0 && completions >= 0 && models >= 0, 'the API routes are untouched');
assert.ok(conf.slice(completions, conf.indexOf('\n }', completions)).includes('proxy_buffering off;'), 'streamed answers are not buffered');
-- for (const [i, verb] of [[ops, 'POST'], [run, 'GET'], [completions, 'POST'], [models, 'GET']]) {
-- const block = conf.slice(i, conf.indexOf('\n }', i));
-- assert.ok(block.includes(`limit_except ${verb} { deny all; }`), verb);
-- assert.ok(block.includes('limit_req zone=ovai_apps'), 'rate-limited per address');
-- for (const hdr of ['X-Real-IP $remote_addr', 'X-Forwarded-For $remote_addr', 'CF-Connecting-IP $remote_addr']) assert.ok(block.includes(hdr), hdr);
-- }
-- const rest = conf.indexOf('location /api/ {');
-- const apiRest = conf.slice(rest, conf.indexOf('\n }', rest));
-- assert.ok(apiRest.includes('default_type application/problem+json;'), 'an unknown API path answers a problem document');
-- assert.ok(/return 404 /.test(apiRest), 'with a 404');
-- assert.ok(!apiRest.includes('proxy_pass'), 'the rest of the API stays host-local');
-- assert.ok(!conf.includes('$proxy_add_x_forwarded_for'));
--});
--
--t.test('the retired ai.openvibe.network address has no vhost (plan T6, 2026-10-10)', () => {
-- assert.ok(!fs.existsSync(path.join(__dirname, '..', 'deploy', 'nginx', 'ai.openvibe.network.conf')));
--});
--
--t.test('the CSPs let Cloudflare Web Analytics load and report (it injects its beacon on this zone)', async () => {
-- assert.match(HOME_CSP, /script-src [^;]*https:\/\/static\.cloudflareinsights\.com/);
-- assert.match(HOME_CSP, /connect-src [^;]*https:\/\/cloudflareinsights\.com/);
-- const stats = await request(h.base, 'GET', '/stats', { headers: { Accept: BROWSER } });
-- // /stats is a Frame page now (2026-11-01), under the home's CSP.
-- assert.strictEqual(stats.headers.get('content-security-policy'), HOME_CSP);
--});
--
--t.test('a browser that asks for a missing page gets the site 404 page; an API client the problem document', async () => {
-- const page = await request(h.base, 'GET', '/no-such-page', { headers: { Accept: BROWSER } });
-- assert.strictEqual(page.status, 404);
-- assert.match(page.headers.get('content-type'), /^text\/html/);
-- assert.ok(page.text.includes('<h1>Page not found</h1>'));
-- assert.ok(page.text.includes('There is no page at this address on ai.openvibe.services. The link may be old or mistyped.'));
-- assert.ok(page.text.includes('id="navbar-mount"'), 'the page is in the site Frame');
-- assert.ok(page.text.includes('<meta name="robots" content="noindex, follow">'));
-- assert.strictEqual(page.headers.get('x-robots-tag'), 'noindex');
-- assert.ok(page.text.includes('class="sc-btn sc-primary" href="/"'), 'home is the first way back');
-- const api = await request(h.base, 'GET', '/api/v1/no-such-thing', { headers: { Accept: BROWSER } });
-- assert.strictEqual(api.status, 404);
-- assert.match(api.headers.get('content-type'), /json/);
-- const notHtml = await request(h.base, 'GET', '/no-such-page', { headers: { Accept: 'application/json' } });
-- assert.strictEqual(notHtml.status, 404);
-- assert.match(notHtml.headers.get('content-type'), /json/);
-- const icon = await request(h.base, 'GET', '/favicon.ico');
-- assert.strictEqual(icon.status, 200);
-- assert.match(icon.headers.get('content-type'), /^image\/svg\+xml/);
--});
--
--t.test('the hero is one run explained, not the generic aura (MOTION-1)', async () => {
-- const r = await request(h.base, 'GET', '/', { headers: { Accept: BROWSER } });
-- const text = r.text;
-- assert.match(text, /<div class="ai-scene" aria-hidden="true">/);
-- assert.match(text, /Why this model[\s\S]*?deepseek-chat[\s\S]*?The cheapest healthy model/);
-- assert.ok(!/sc-aura-glow/.test(text), 'no generic aura');
++ assert.ok(conf.includes('location = /robots.txt'));
});
--t.test('shutdown', async () => { await h.stop(); });
++t.test('stop', async () => { await h.stop(); });
t.run();
ddiff --git a/test/stats-page.test.js b/test/stats-page.test.js
ddeleted file mode 100644
iindex bbdaf36..0000000
---- a/test/stats-page.test.js
++++ /dev/null
@@@ -1,74 +0,0 @@
--'use strict';
--// GET /stats: the public price/latency page. Server-rendered, no sign-in; rows come from provider_stats_daily
--// (quotas.statsFor) and the platform.rate-card@1 cards only. Nothing per caller ever reaches it: not the
--// requester, the attribution, a person's own-key provider (byo:<owner>:<subject>), a base URL or a secret.
--const assert = require('assert');
--const { boot, request, suite } = require('./helpers');
--
--const t = suite('stats-page');
--let h;
--
--const SEAM_URL = 'http://127.0.0.1:9/seam-internal';
--const ctx = { requesterType: 'service', requesterId: 'live', attributionKey: 'live:user:4242', workflowKey: 'default.generate' };
--const attempt = (provider_key, model_key, status, latency_ms) => ({ provider_key, model_key, status, latency_ms, cost_usd: 0.001, tokens_in: 10, tokens_out: 5, route_key: null });
--
--t.test('boot', async () => {
-- h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
--});
--
--t.test('no samples: 200 without auth, text/html, a short public cache and an honest empty state', async () => {
-- const r = await request(h.base, 'GET', '/stats');
-- assert.strictEqual(r.status, 200, r.text);
-- assert.match(r.headers.get('content-type'), /^text\/html/);
-- assert.strictEqual(r.headers.get('cache-control'), require('openvibe-shared/cache-policy').htmlHeaders());
-- assert.match(r.headers.get('cache-control'), /^public, max-age=\d+/);
-- assert.ok(r.headers.get('content-security-policy').includes("connect-src 'self' https://openvibe.network"), 'the home CSP: the Frame reads the Network');
-- assert.match(r.text, /no provider calls in the last 7 days/);
-- assert.doesNotMatch(r.text, /<table/);
-- // The site's own Frame and type (domain sweep, 2026-11-01: it was the operator console's bare light page).
-- assert.ok(r.text.includes('<div id="navbar-mount"></div>') && r.text.includes('/shared/showcase.css'), 'the Frame and the site stylesheet');
-- assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one heading');
--});
--
--t.test('seeded stats: one row per provider and model with p50, p95, success rate, price and allowance', async () => {
-- await h.registry.upsertProvider({ key: 'seam', kind: 'http', auth_mode: 'bearer', secret_ref: 'env:SEAM_SECRET_KEY', base_url: SEAM_URL, capabilities: ['chat', 'json'], timeout_ms: 5000 });
-- await h.registry.upsertModel({ provider_key: 'seam', model_key: 'seam-model', type: 'chat', cost: { in_per_mtok: 3, out_per_mtok: 15, cached_per_mtok: 0.3, free_allowance: 1000000, reset_period: 'month' } });
-- await h.quotas.account(ctx, [], {
-- provider: 'seam', model: 'seam-model', writeUsage: false,
-- attempts: [attempt('seam', 'seam-model', 'ok', 80), attempt('seam', 'seam-model', 'ok', 90), attempt('seam', 'seam-model', 'ok', 150), attempt('seam', 'seam-model', 'error', 3000)],
-- });
-- // A person's own key: its runs are recorded under byo:<owner>:<subject>, never a public row.
-- await h.quotas.account(ctx, [], { provider: 'byo:user:usr_owner1:usr_subject1', model: 'own-model', writeUsage: false, attempts: [attempt('byo:user:usr_owner1:usr_subject1', 'own-model', 'ok', 40)] });
--
-- const r = await request(h.base, 'GET', '/stats');
-- assert.strictEqual(r.status, 200, r.text);
-- assert.doesNotMatch(r.text, /no provider calls/);
-- const rows = r.text.match(/<tbody>([\s\S]*)<\/tbody>/)[1].split('</tr>').filter((x) => x.includes('<td'));
-- assert.strictEqual(rows.length, 1, r.text);
-- const cells = [...rows[0].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/g)].map((m) => m[1].replace(/<[^>]+>/g, '').trim());
-- // Provider, Model, Capability, p50, p95, Success rate, Price, Free allowance (bucket upper bounds: 100 ms, 3200 ms).
-- assert.deepStrictEqual(cells.slice(0, 6), ['seam', 'seam-model', 'chat', '100 ms', '3,200 ms', '75.0 % (3 / 4)']);
-- assert.match(cells[6], /in \$3\.0000 · cached \$0\.3000 · out \$15\.0000 per 1M tokens/);
-- assert.match(cells[7], /in 1,000,000 tokens \/ month/);
-- for (const h2 of ['Provider', 'Model', 'Capability', 'p50', 'p95', 'Success rate', 'Price', 'Free allowance']) assert.ok(r.text.includes(`<th scope="col">${h2}</th>`), h2);
-- // On a phone each row is a card: every cell names its column.
-- for (const label of ['Provider', 'Model', 'Capability', 'p50', 'p95', 'Success rate', 'Price', 'Free allowance']) assert.ok(rows[0].includes(`data-label="${label}"`), label);
-- assert.match(r.text, /last 7 UTC days \(\d{4}-\d{2}-\d{2} to \d{4}-\d{2}-\d{2}\)/);
--});
--
--t.test('a subject with free allowance used this period is still not on the page', async () => {
-- const free = await h.quotas.freeAllowance.claim('live:user:4242', 'seam', 'seam-model', { in: 10 });
-- assert.strictEqual(free.tokens, 10);
-- assert.ok((await h.quotas.freeAllowance.current()).some((r) => r.subject === 'live:user:4242'), 'the staff console has the row');
--});
--
--t.test('no caller identifiers, internal URLs or secrets in the HTML', async () => {
-- const r = await request(h.base, 'GET', '/stats');
-- for (const leak of ['byo:', 'usr_owner1', 'usr_subject1', 'own-model', 'service:live', 'live:user:4242', 'default.generate', 'ai:run_', SEAM_URL, '127.0.0.1', 'SEAM_SECRET_KEY', 'env:']) {
-- assert.ok(!r.text.includes(leak), `the page shows ${leak}`);
-- }
--});
--
--t.test('stop', async () => { await h.stop(); });
--
--t.run();