openvibers/openvibe.ai Commits
The human site moved to OpenVibe.Exchange; every API route stays (#346)
Changes
@@@ -10,6 +10,9 @@ PORT=4700HOST=127.0.0.1NODE_ENV=developmentBASE_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@@@ -5,7 +5,12 @@**Status:** alpha (roadmap Wave 13). Deployed on the host since 2026-09-23 on `127.0.0.1:4700` and inproduction 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).@@@ -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).@@@ -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, keysapp.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 });});@@@ -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']) },@@@ -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');});@@@ -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', at.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 } });@@@ -67,11 +67,11 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, researchreturn { ...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; }@@@ -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'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="${SITE}/v1"`) || html.includes(`base_url="${SITE}/v1"`), 'the Python sample points at /v1');-- assert.ok(html.includes(`baseURL: '${SITE}/v1'`) || 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, ''')}</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();ddeleted file mode 100644@@@ -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();
@@ -10,6 +10,9 @@ PORT=4700
@@ -10,6 +10,9 @@ PORT=4700
HOST=127.0.0.1
HOST=127.0.0.1
NODE_ENV=development
NODE_ENV=development
BASE_URL=http://localhost:4700
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).
# 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.
# 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
#AI_CORS_ORIGINS=https://ai.openvibe.services,https://openvibe.services
@@ -5,7 +5,12 @@
@@ -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
**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
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):
`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`
**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.
**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).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -8,9 +8,10 @@
@@ -8,9 +8,10 @@
## 2026-10-10 (its last service caller stopped on 10-02).
## 2026-10-10 (its last service caller stopped on 10-02).
##
##
## The Node service (port 4700, systemd unit openvibe-ai) serves:
## 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/health, /api/ready
## /api/v1/{chat,generate,summarize,classify,extract,embed} (POST) and /api/v1/runs/:id (GET)
## /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
## developer apps (ai.app.run, app tokens from OpenVibe.Network): open here, rate-limited
@@ -220,11 +221,21 @@ server {
@@ -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; }
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 = /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 = /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 = /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.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; }
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; }
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).
# 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).
# The catch-all below passes GET and HEAD only, so every visitor's report was refused with a 403 (2026-10-24).
@@ -16,10 +16,7 @@ const { createOps } = require('./ops');
@@ -16,10 +16,7 @@ const { createOps } = require('./ops');
const { consoleRouter } = require('./console');
const { consoleRouter } = require('./console');
const { createPersonSessions } = require('./person-session');
const { createPersonSessions } = require('./person-session');
const { runPageRouter } = require('./run-pages');
const { runPageRouter } = require('./run-pages');
-const views = require('./console/views');
-const cachePolicy = require('openvibe-shared/cache-policy');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
-const home = require('./home');
const pkg = require('../package.json');
const pkg = require('../package.json');
const { adminRoutes } = require('openvibe-shared/config');
const { adminRoutes } = require('openvibe-shared/config');
@@ -86,22 +83,8 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -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
// 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.
// /shared/* URLs from this repo's pinned openvibe-shared; each answer sets its own asset cache policy.
app.use('/shared', ovServe.handler());
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.
// 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'] }));
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
@@ -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(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, settings, personSessions, abuse, priceBook }));
app.use(runPageRouter({ runs, personSessions, log }));
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');
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([
res.type('text/plain').send([
'OpenVibe.AI: providers, models, routing, versioned prompt templates and workflows, runs, citations, scoped cache, quotas and audit.',
'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)',
'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',
'GET /api/v1/runs/:id POST /api/v1/runs/:id/cancel|retry',
'POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}',
'POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
'GET /api/v1/workflows | templates | routes | providers | models | quotas | usage | audit',
'GET /api/health, /api/ready, /release.json',
'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 /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',
'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',
'Developer apps: https://openvibe.services/projects',
'AI output is a draft/evidence package attributed to a workflow, model and run, never to a person.',
'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
@@ -346,15 +242,26 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
'',
'',
].join('\n'));
].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) => {
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 });
return http.sendProblem(res, 404, 'ai.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov });
});
});
@@ -48,6 +48,9 @@ function load(env = process.env) {
@@ -48,6 +48,9 @@ function load(env = process.env) {
nodeEnv,
nodeEnv,
isProduction,
isProduction,
baseUrl,
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;
// 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 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']) },
cors: { origins: list(env.AI_CORS_ORIGINS, ['https://ai.openvibe.services', 'https://openvibe.services']) },
@@ -1,41 +1,40 @@
@@ -1,41 +1,40 @@
'use strict';
'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 assert = require('assert');
const { boot, request, suite } = require('./helpers');
const { boot, request, suite } = require('./helpers');
-const t = suite('agent-twins');
+const t = suite('agent-twins-moved');
let h;
let h;
-const HTML = { accept: 'text/html' };
+const EXCHANGE = 'https://openvibe.exchange';
+const location = (r) => String(r.headers.get('location') || '');
t.test('boot', async () => {
t.test('boot', async () => {
h = await boot({ env: { AI_STUB_FALLBACK: 'false' } });
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', '/');
const index = await request(h.base, 'GET', '/');
assert.match(String(index.headers.get('content-type')), /^text\/plain/, 'curl still gets the API index');
assert.match(String(index.headers.get('content-type')), /^text\/plain/, 'curl still gets the API index');
});
});
@@ -22,9 +22,9 @@ t.test('config has exact public origins and accepts an override', () => {
@@ -22,9 +22,9 @@ t.test('config has exact public origins and accepts an override', () => {
t.test('boot', async () => { h = await boot(); });
t.test('boot', async () => { h = await boot(); });
t.test('listed origins can read only the two public GETs without credentials', async () => {
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.strictEqual(r.headers.get('access-control-allow-origin'), origin);
assert.match(r.headers.get('vary'), /\bOrigin\b/i);
assert.match(r.headers.get('vary'), /\bOrigin\b/i);
assert.strictEqual(r.headers.get('access-control-allow-credentials'), null);
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
@@ -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 () => {
t.test('unlisted and merely similar origins receive no CORS permission', async () => {
for (const origin of [rejected, `${allowed}:443`, 'null']) {
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);
assertNoCors(r.headers);
}
}
const r = await request(h.base, 'GET', '/release.json', { headers: { Origin: rejected } });
const r = await request(h.base, 'GET', '/release.json', { headers: { Origin: rejected } });
@@ -67,11 +67,11 @@ async function boot({ env = {}, dir, clock, fetchImpl, credentialFetch, research
@@ -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(); } };
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 };
const h = { ...headers };
if (tok) h.Authorization = `Bearer ${tok}`;
if (tok) h.Authorization = `Bearer ${tok}`;
if (body !== undefined) h['Content-Type'] = 'application/json';
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();
const text = await res.text();
let json = null;
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
try { json = text ? JSON.parse(text) : null; } catch { json = null; }
@@ -1,229 +1,108 @@
@@ -1,229 +1,108 @@
'use strict';
'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');
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 fs = require('fs');
const path = require('path');
const path = require('path');
const { boot, request, suite } = require('./helpers');
const { boot, request, suite } = require('./helpers');
-const { HOME_CSP, renderHome, FAQ, DESCRIPTION } = require('../server/home');
const SITE = 'https://ai.openvibe.services';
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 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;
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 () => {
t.test('boot with the public origin as BASE_URL', async () => {
h = await boot({ env: { BASE_URL: SITE } });
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'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="${SITE}/v1"`) || html.includes(`base_url="${SITE}/v1"`), 'the Python sample points at /v1');
- assert.ok(html.includes(`baseURL: '${SITE}/v1'`) || 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, ''')}</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']) {
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');
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');
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 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 run = conf.indexOf('location ~ ^/api/v1/runs/[A-Za-z0-9_-]+$ {');
const completions = conf.indexOf('location = /v1/chat/completions {');
const completions = conf.indexOf('location = /v1/chat/completions {');
const models = conf.indexOf('location = /v1/models {');
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');
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();
t.run();
deleted file mode 100644
deleted file mode 100644
@@ -1,74 +0,0 @@
@@ -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();