openvibers/openvibe.exchange Commits
Move Exchange chat entry points and export history to Chat (#136)
Changes
@@@ -17,7 +17,8 @@ for a person — and keeps only two things of its own: a person's chats and thei|---|---|| `/` | "Every AI model, one account": search, popular now, cheapest per million, uncensored, community machines, the endpoint, AI in OpenVibe. Every number is live. || `/models`, `/m/<org>/<model>` | Every model and every offer that serves it: price in, out and cached per million, measured p50/p95, uptime, data policy, a **Try it** box, curl and the SDK line. |--| `/chat` | One model, or up to three side by side; system prompt, temperature, history in the account. The shared conversation UI (`openvibe-shared/thread`, `ov-thread.js`). |++| `/chat` and `/chat/*` | 301 to `https://openvibe.chat/vibe`, carrying `?models=` and mapping old `@vN` model ids. Model pages' Try it links go there directly. |++| `/api` | The endpoint, model ids (`deepseek/deepseek-chat`, `openvibe/auto`), keys from Network, aggregate usage, and the link to AI's own provider-keys page (`/you`, a session page). || `/start` | Start building: one key, one endpoint, the OpenAI/Anthropic samples, four steps, AI's six operations, and the sandbox and production limits. || `/why` | Why this model: the routing rule and a signed-in demo that shows a real run's explain — the objective, the selected offer, and every candidate with its cost, latency and reason. |@@@ -27,6 +28,8 @@ for a person — and keeps only two things of its own: a person's chats and thei| `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. || `/status` | Uptime per offer, the model changelog and incidents. |++The old chat rows remain for a person's one-time import into Chat. `GET /internal/exchange/chat/export` requires Chat's service token with `exchange.chat.export` and the authenticated person's `x-ov-subject`; it returns only that person's unsealed chats.++Every page is server-rendered with openvibe-shared/kit and complete without JavaScript. A page rendered for asigned-in person is `private, no-store`; a public page is `public, max-age=60`.@@@ -214,6 +214,31 @@ async function createApp(opts = {}) {app.locals.sharingTimer = timer;}++ // Chat imports only the signed-in person's unsealed history. The service token must carry++ // the narrow export capability; Chat supplies the subject it just authenticated.++ app.get('/internal/exchange/chat/export', principal.middleware, async (req, res) => {++ const p = req.principal;++ const subject = String(req.get('x-ov-subject') || '');++ if (!p || p.kind !== 'app' || p.claims.sub !== 'svc:chat' || !p.claims.cap.includes('exchange.chat.export') || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject))++ return res.status(403).json({ error: 'Chat export is unavailable to this caller' });++ const list = (await chats.list(subject, 200)).filter((c) => !c.sealed);++ const exported = await Promise.all(list.map(async (c) => {++ const full = await chats.get(subject, c.id);++ return { id: c.id, title: c.title, models: c.models, created_at: full.created_at, updated_at: c.updated_at,++ messages: (await chats.messages(subject, c.id)).map((m) => ({ role: m.role, model: m.model,++ content: m.content, created_at: m.created_at, meta: m.meta })) };++ }));++ return res.set('Cache-Control', 'private, no-store').json({ count: exported.length, chats: exported });++ });++++ // Talking to models moved to Chat. Carry an old model selection across the 301.++ app.use(/^\/chat(?:\/.*)?$/, (req, res) => {++ const ids = String(req.query.models || '').split(',').map((x) => x.replace(/@v[0-9]+$/i, ''))++ .filter((x) => x !== 'openvibe/auto' && /^[a-z0-9._/@:-]{2,256}$/i.test(x)).slice(0, 3);++ const suffix = ids.length ? `?models=${encodeURIComponent(ids.join(','))}` : '';++ return res.redirect(301, `https://openvibe.chat/vibe${suffix}`);++ });++// ── Exchange v1's JSON API: gone ────────────────────────app.use('/api/v1', (req, res) => {res.set('Cache-Control', 'public, max-age=86400');@@@ -148,26 +148,8 @@ function createPageRoutes(ctx) {}r.get('/m/:org/:model', (req, res) => renderModel(req, res));-- r.post('/m/:org/:model/try', async (req, res) => {-- if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to try it', text: 'A try is one run on your own free allowance.', next: `/m/${req.params.org}/${req.params.model}` });-- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);-- const text = String((req.body && req.body.text) || '').slice(0, config.chat.maxChars);-- if (!text.trim()) return renderModel(req, res, { problem: 'Write something to send.', status: 422 });-- const [catalog, community] = await Promise.all([catalogOf(), communityOffers()]);-- const model = await modelFrom(catalog, req.params.org, req.params.model);-- if (!model) return renderModel(req, res);-- if (model.uncensored && !(await adult.confirmed(owner(req)))) {-- return page(req, res, { title: model.name, index: false, body: raw(views.modelPage({ model, viewer: req.viewer, problem: 'Confirm you are 18 or over first.', adult: false, community })) }, 403);-- }-- const best = model.offers.find((o) => o.id) || model.offers[0];-- const modelId = (best && best.id) || 'openvibe/auto';-- try {-- const out = await ai.chat({ model: modelId, messages: [{ role: 'user', content: text }], subject: owner(req) });-- return renderModel(req, res, { result: out, text });-- } catch (err) {-- return renderModel(req, res, { problem: err.message || 'The model did not answer.', text, status: err.status === 429 ? 429 : 502 });-- }-- });++ r.post('/m/:org/:model/try', (req, res) => res.redirect(301,++ `https://openvibe.chat/vibe?models=${encodeURIComponent(`${req.params.org}/${req.params.model}`)}`));// ── Chat ─────────────────────────────────────────────────async function chatCatalog() {@@@ -22,7 +22,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');// "OpenVibe..."). Icons are the navbar's own glyphs; the brand is the way home.const NAV = [{ label: 'Models', href: '/models', icon: 'fa-microchip' },-- { label: 'Chat', href: '/chat', icon: 'fa-comments' },++ { label: 'Chat', href: 'https://openvibe.chat/vibe', icon: 'fa-comments' },{ label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line' },{ label: 'Start building', href: '/start', icon: 'fa-bolt' },@@@ -11,6 +11,8 @@ const { esc } = require('../render/html');const { mark, product, productMark } = require('./marks');const ENDPOINT = 'https://api.openvibe.exchange/v1/chat/completions';++const CHAT = 'https://openvibe.chat/vibe';++const chatModel = (m) => `${CHAT}?models=${encodeURIComponent(m.slug)}`;const money = (n, free = false) => (free ? 'free' : n == null ? '–' : n === 0 ? '$0' : `$${Number(n) < 0.1 ? Number(n).toPrecision(2) : Number(n).toFixed(2)}`);const inOut = (price) => (price ? `${money(price.in)} in · ${money(price.out)} out` : 'no per-token price');@@@ -151,7 +153,7 @@ function modelRow(m, { withTry = true } = {}) {if (pv) bits.push(pv.short);return `<li class="ex-mrow"><a class="ex-mrow-main" href="${hrefOf(m)}">${mark(familyLabel(m))}<span class="ex-mrow-text"><span class="ex-mrow-title"><b>${esc(m.name)}</b>${badgesOf(m, best)}</span><small>${bits.map((b) => esc(b)).join(' · ')}</small></span></a>--${withTry ? `<a class="ex-try-link" href="${hrefOf(m)}#try">Try it</a>` : ''}++${withTry ? `<a class="ex-try-link" href="${chatModel(m)}">Try it</a>` : ''}</li>`;}@@@ -239,7 +241,7 @@ function previewCard(preview) {<p class="ex-preview-tag">A side-by-side answer <span>example words · live prices</span></p><p class="ex-preview-q">“Summarize this contract in three bullets.”</p><div class="ex-preview-grid">${cells}</div>--<p class="ex-more"><a href="/chat">Open the real chat →</a></p>++<p class="ex-more"><a href="https://openvibe.chat/vibe">Open the real chat →</a></p></aside>`;}@@@ -288,7 +290,7 @@ function modelsPage({ catalog, q = '', family = '', sort = '', community = nullconst familyLinks = families.slice(0, 10).map((f) => `<a class="ex-chip${f === family ? ' is-on' : ''}" href="/models?family=${encodeURIComponent(f)}">${esc(f)}</a>`).join('');return `<div class="ex-wrap ex-page">--${kit.head({ title: 'Models', sub: `${models.length} model${models.length === 1 ? '' : 's'} · ${offerCount(models)} offers, live`, actions: [kit.button({ href: '/chat', label: 'Chat', icon: 'chat', kind: 'quiet' })] })}++${kit.head({ title: 'Models', sub: `${models.length} model${models.length === 1 ? '' : 's'} · ${offerCount(models)} offers, live`, actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model or a provider', value: q, icon: 'search' })}<div class="ex-chips" role="group" aria-label="Sort">${links}</div>${families.length ? `<div class="ex-chips" role="group" aria-label="Family">${familyLinks}</div>` : ''}@@@ -336,7 +338,7 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', aduptime: o.uptime == null ? '–' : pct(o.uptime),privacy: `${p ? kit.badge(p.label, p.kind) : ''}${refuses ? `<br><small>${esc(refuses)}</small>` : ''}` || '–',policy: policyText(o.data_policy),-- try: '<a class="ex-mini" href="#try">Try it</a>',++ try: `<a class="ex-mini" href="${chatModel(model)}">Try it</a>`,};});@@@ -351,7 +353,7 @@ ${stats.length ? kit.stats(stats) : ''}${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}${panel({ title: 'Presets', note: 'A preset fixes the router’s floor, ceiling and policy, and you name it as the model.', id: 'presets', body: presetsBox() })}--${panel({ title: 'Try it', id: 'try', body: tryBox(model, viewer, { result, problem, text, adult }) })}++${panel({ title: 'Try it', id: 'try', body: kit.button({ href: chatModel(model), label: `Chat with ${model.name}` }) })}${panel({ title: 'Use it from code', id: 'code', body: `${kit.snippet({ code, id: 'model-curl' })}${kit.snippet({ code: sdk, id: 'model-sdk' })}<p class="ex-more">The endpoint, keys and usage: <a href="/api">the API</a>.</p>` })}@@@ -383,7 +385,7 @@ function presetsBox() {function sealedChat({ chat = null, models = [], networkUrl = '', subject = '' }) {const options = models.slice(0, 40).map((m) => `<option value="${esc(m.slug)}">${esc(m.name)}${m.uncensored ? ' · 18+' : ''}</option>`).join('');return `<div class="ex-wrap ex-page ex-sealed" id="ex-sealed" data-chat="${chat ? esc(chat.id) : ''}" data-subject="${esc(subject)}" data-network="${esc(networkUrl)}">--${kit.head({ title: chat ? 'A sealed chat' : 'A new sealed chat', sub: 'Your history is sealed with your Locker.', back: { href: '/chat', label: 'Chat' }, actions: [kit.button({ href: '/chat/new', label: 'A normal chat', kind: 'quiet' })] })}++${kit.head({ title: chat ? 'A sealed chat' : 'A new sealed chat', sub: 'Your history is sealed with your Locker.', back: { href: CHAT, label: 'Chat' }, actions: [kit.button({ href: CHAT, label: 'A normal chat', kind: 'quiet' })] })}<noscript><p class="ex-prose">A sealed chat needs JavaScript: the sealing and the opening happen in your browser, and nowhere else.</p></noscript><div class="ex-sealed-card"><p class="ex-sealed-title" data-sealed-title hidden></p>@@@ -471,7 +473,7 @@ ${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.no<p>${kit.button({ label: 'Save the switch', type: 'submit', kind: 'quiet' })}</p></form>`);return `<div class="ex-wrap ex-page">--${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: '/chat', label: 'Chat', icon: 'chat', kind: 'quiet' })] })}++${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}${panel({ title: 'One endpoint', body: `<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>@@@ -735,7 +737,7 @@ ${kit.head({ title: 'Uncensored', sub: 'Abliterated models, for adults. The same${gate || ''}${panel({ title: 'The floor on every offer, uncensored or not', id: 'floor', body: floorText(true) })}${adult ? panel({ title: 'Uncensored models', id: 'models', body: modelList(models, { empty: 'No uncensored model is live right now. Featherless and Refuseless offers appear here when their keys are on.' }) }) : ''}--${panel({ title: 'How to use it', body: '<p class="ex-prose">Pick an uncensored model in <a href="/chat">chat</a>, or send its exact id to the API. Your 18+ confirmation sits on your account, so it follows you here; the floor applies to every request on this tier, and a breach ends the account.</p>' })}++${panel({ title: 'How to use it', body: '<p class="ex-prose">Pick an uncensored model in <a href="https://openvibe.chat/vibe">chat</a>, or send its exact id to the API. Your 18+ confirmation sits on your account, so it follows you here; the floor applies to every request on this tier, and a breach ends the account.</p>' })}${companionsPolicy()}${changelogPanel()}</div>`;@@@ -1,124 +1,51 @@'use strict';--/**-- * /chat on the shared conversation UI: one model or three side by side, a system prompt and a temperature, the-- * history in the person's account, the daily allowance, the 18+ gate for an uncensored model, and one account's-- * chat never readable by another.-- */--const assert = require('assert');++/** Exchange's former chat redirects; Chat alone can export a signed-in person's history. */++const assert = require('node:assert/strict');++const crypto = require('node:crypto');++const { serviceAuth } = require('openvibe-contracts');const { boot, check, done } = require('./helpers/boot');const { createFakeAi } = require('./helpers/ai');(async () => {-- const ai = createFakeAi();-- const t = await boot({ ai });++ const t = await boot({ ai: createFakeAi() });const alice = t.network.addUser('alice');const bob = t.network.addUser('bob');++ const token = (caps) => {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: t.network.issuer(), sub: 'svc:chat', actor_type: 'service',++ aud: ['openvibe.exchange'], cap: caps, iat: now, exp: now + 300, jti: crypto.randomUUID() }, t.network.privatePem);++ };try {-- const confirmed = await t.get('/uncensored/confirm', { as: alice, form: { adult: 'yes' } });-- assert.strictEqual(confirmed.status, 303, 'alice confirms 18+ for the comparison');---- await check('signed out, chat asks for a sign-in', async () => {-- const r = await t.get('/chat');-- assert.strictEqual(r.status, 401);-- assert.ok(r.text.includes('Sign in with OpenVibe'));-- });---- await check('the start page is the shared conversation UI with model chips', async () => {-- const r = await t.get('/chat/new', { as: alice });-- assert.strictEqual(r.status, 200, r.text);-- assert.ok(r.text.includes('/shared/thread.css') && r.text.includes('/shared/ov-thread.js'));-- assert.ok(r.text.includes('ovt-composer') && r.text.includes('ovk-sidebar'));-- assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Llama 3 8B abliterated'), 'the model chips');-- assert.ok(r.text.includes('System prompt') && r.text.includes('Temperature'));-- });---- let chatUrl = null;-- await check('a message runs the chosen models side by side and stores the history', async () => {-- const r = await t.get('/chat/send', {-- as: alice,-- form: {-- text: 'What is a mutex?',-- 'models_deepseek/deepseek-chat': '1',-- 'models_meta-llama/llama-3-8b-abliterated': '1',-- system: 'Be brief.',-- temperature: '0.4',-- },-- });-- assert.strictEqual(r.status, 303, r.text);-- chatUrl = r.headers.get('location');-- assert.match(chatUrl, /^\/chat\/exc_/);-- assert.strictEqual(ai.calls.length, 2, 'one run per model');-- assert.deepStrictEqual(ai.calls.map((c) => c.model).sort(), ['deepseek/deepseek-chat@v1', 'openvibe/auto'], 'the exact id where one exists');-- assert.ok(ai.calls.every((c) => c.subject === alice.subject), 'each run is on the person’s behalf');-- assert.strictEqual(ai.calls[0].temperature, 0.4);-- assert.strictEqual(ai.calls[0].messages[0].role, 'system');---- const page = await t.get(chatUrl, { as: alice });-- assert.strictEqual(page.status, 200);-- assert.ok(page.text.includes('What is a mutex?'));-- assert.ok(page.text.includes('An answer from deepseek/deepseek-chat@v1.'));-- assert.ok(page.text.includes('An answer from openvibe/auto.'));-- assert.match(page.headers.get('cache-control'), /private|no-store/);-- assert.ok(page.text.includes('ex-answer-grid') && page.text.includes('data-count="2"'), 'the two answers sit in one round');-- assert.ok(page.text.includes('ex-receipt') && page.text.includes('Why this offer'), 'a folded receipt under each answer');-- assert.ok(page.text.includes('$0.000120') && page.text.includes('420 ms'), 'the receipt shows the price and latency');-- assert.ok(page.text.includes('ex-mk--deepseek') && page.text.includes('ex-mk--llama'), 'answers carry family marks');-- assert.ok(!page.text.includes('class="ovt-avatar"'), 'no stray empty avatar above an answer');-- });---- await check('the next message carries the history, and a second model’s answers never bleed across', async () => {-- const before = ai.calls.length;-- const r = await t.get(`${chatUrl}/send`, {-- as: alice,-- form: { text: 'And a semaphore?', 'models_deepseek/deepseek-chat': '1', 'models_meta-llama/llama-3-8b-abliterated': '1' },-- });-- assert.strictEqual(r.status, 303);-- const fresh = ai.calls.slice(before);-- assert.strictEqual(fresh.length, 2);-- const deep = fresh.find((c) => c.model === 'deepseek/deepseek-chat@v1');-- assert.deepStrictEqual(deep.messages.map((m) => m.role), ['user', 'assistant', 'user'], 'its own answer is in the history');-- assert.strictEqual(deep.messages[1].content, 'An answer from deepseek/deepseek-chat@v1.');-- });---- await check('another account cannot read the chat', async () => {-- assert.strictEqual((await t.get(chatUrl, { as: bob })).status, 404);-- assert.strictEqual((await t.get('/chat', { as: bob })).status, 200, 'bob has his own start page');-- });---- await check('an uncensored model needs the 18+ confirmation first', async () => {-- const before = ai.calls.length;-- const r = await t.get('/chat/send', { as: bob, form: { text: 'Hello', 'models_meta-llama/llama-3-8b-abliterated': '1' } });-- assert.strictEqual(r.status, 403);-- assert.ok(r.text.includes('18 or over'));-- assert.strictEqual(ai.calls.length, before, 'no run happened');-- const confirm = await t.get('/uncensored/confirm', { as: bob, form: { adult: 'yes' } });-- assert.strictEqual(confirm.status, 303);-- const ok = await t.get('/chat/send', { as: bob, form: { text: 'Hello', 'models_meta-llama/llama-3-8b-abliterated': '1' } });-- assert.strictEqual(ok.status, 303);-- });---- await check('a cross-site send is refused', async () => {-- const r = await t.get('/chat/send', { as: alice, json: { text: 'hi' }, origin: 'https://evil.test' });-- assert.strictEqual(r.status, 403);-- });---- await check('a chat belongs to the person who made it, and can be deleted', async () => {-- const del = await t.get(`${chatUrl}/delete`, { as: alice, method: 'POST' });-- assert.strictEqual(del.status, 303);-- assert.strictEqual((await t.get(chatUrl, { as: alice })).status, 404);-- });-- } finally { await t.close(); }---- const capped = await boot({ ai: createFakeAi(), env: { EXCHANGE_CHAT_PER_DAY: '1' } });-- const carol = capped.network.addUser('carol');-- try {-- await check('the daily allowance stops at its number', async () => {-- const first = await capped.get('/chat/send', { as: carol, form: { text: 'one' } });-- assert.strictEqual(first.status, 303);-- const second = await capped.get('/chat/send', { as: carol, form: { text: 'two' } });-- assert.strictEqual(second.status, 429);-- assert.ok(second.text.includes('resets at midnight UTC'));-- });-- } finally { await capped.close(); }-- done();--})();++ await check('every old chat path redirects to Vibe and carries selected models', async () => {++ for (const path of ['/chat', '/chat/new', '/chat/exc_old', '/chat/exc_old/send']) {++ const r = await t.get(path, { as: alice });++ assert.equal(r.status, 301, path);++ assert.equal(r.headers.get('location'), 'https://openvibe.chat/vibe');++ }++ const selected = await t.get('/chat?models=deepseek%2Fdeepseek-chat%2Cqwen%2Fqwen3-32b');++ assert.equal(selected.status, 301);++ assert.equal(selected.headers.get('location'), 'https://openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat%2Cqwen%2Fqwen3-32b');++ const old = await t.get('/chat?models=deepseek%2Fdeepseek-chat%40v1');++ assert.equal(old.headers.get('location'), 'https://openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat');++ const page = await t.get('/m/deepseek/deepseek-chat');++ assert.ok(page.text.includes('https://openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat'));++ });++ await check('export requires Chat capability and contains only the named person’s unsealed chats', async () => {++ const mine = await t.ctx.chats.create({ owner: alice.subject, title: 'Alice history', models: ['deepseek/deepseek-chat'] });++ await t.ctx.chats.append(alice.subject, mine.id, { role: 'user', content: 'Hello' });++ await t.ctx.chats.append(alice.subject, mine.id, { role: 'assistant', model: 'deepseek/deepseek-chat', content: 'Hi' });++ const theirs = await t.ctx.chats.create({ owner: bob.subject, title: 'Bob history' });++ await t.ctx.chats.append(bob.subject, theirs.id, { role: 'user', content: 'Private' });++ await t.ctx.chats.create({ owner: alice.subject, title: 'Sealed', sealed: true });++ const path = '/internal/exchange/chat/export';++ assert.equal((await t.get(path, { bearer: token([]), headers: { 'x-ov-subject': alice.subject } })).status, 403);++ const out = await t.get(path, { bearer: token(['exchange.chat.export']), headers: { 'x-ov-subject': alice.subject } });++ assert.equal(out.status, 200, out.text);++ assert.equal(out.json().count, 1);++ assert.deepEqual(out.json().chats[0].messages.map((m) => m.content), ['Hello', 'Hi']);++ const other = await t.get(path, { bearer: token(['exchange.chat.export']), headers: { 'x-ov-subject': bob.subject } });++ assert.equal(other.json().count, 1);++ assert.equal(other.json().chats[0].title, 'Bob history');++ });++ } finally { await t.close(); done(); }++})().catch((err) => { console.error(err); process.exitCode = 1; });@@@ -10,8 +10,8 @@ assert.ok(inBar.length <= 6, `at most six places in the bar, not ${inBar.length}assert.ok(NAV.every((l) => /^fa-[a-z0-9-]+$/.test(l.icon || '')), 'every place has an icon');assert.ok(!NAV.some((l) => l.href === '/'), 'the brand is the way home');assert.deepStrictEqual(new Set(NAV.map((l) => l.href)).size, NAV.length, 'no place twice');--for (const want of ['/models', '/chat', '/arena', '/start', '/api', '/why', '/own', '/sell', '/uncensored', '/ai', '/faq', '/status']) {++for (const want of ['/models', 'https://openvibe.chat/vibe', '/arena', '/start', '/api', '/why', '/own', '/sell', '/uncensored', '/ai', '/faq', '/status']) {assert.ok(NAV.some((l) => l.href === want), `${want} is reachable from the bar or More`);}--if (Array.isArray(PAGES)) for (const l of NAV) assert.ok(PAGES.some((p) => p.path === l.href) || ['/chat', '/status', '/arena', '/rankings'].includes(l.href), `${l.href} is a page`);++if (Array.isArray(PAGES)) for (const l of NAV) assert.ok(PAGES.some((p) => p.path === l.href) || ['https://openvibe.chat/vibe', '/status', '/arena', '/rankings'].includes(l.href), `${l.href} is a page`);console.log('nav: six places in the bar with icons, the rest under More, every page reachable');@@@ -58,7 +58,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');assert.ok(r.text.includes('no training') && r.text.includes('no retention') && r.text.includes('cn'), 'the data policy');assert.ok(r.text.includes('99.0%'), 'uptime');assert.ok(r.text.includes('400 ms'), 'p50');-- assert.ok(r.text.includes('Sign in to try it'), 'a signed-out try asks for sign-in');++ assert.ok(r.text.includes('https://openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat'), 'Try it opens Chat with this model');assert.ok(r.text.includes('Private'), 'the privacy label on an offer');assert.ok(r.text.includes('Standard') || r.text.includes('No training'));assert.ok(r.text.includes('Presets') && r.text.includes('openvibe/economy'), 'the presets box');@@@ -119,8 +119,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');const paid = await t.get('/m/qwen/qwen3-32b');assert.ok(!paid.text.includes('Free to try'), 'no badge when the field is absent');const chat = await t.get('/chat/new', { as: u });-- assert.ok(chat.text.includes('Free to try'), 'the picker marks the free-tier models');-- assert.ok(chat.text.includes('need your own balance or key'), 'and says what the others need');++ assert.strictEqual(chat.status, 301, 'the picker now lives in Chat');});await check('status: uptime per offer, the changelog and incidents', async () => {@@@ -1,34 +1,11 @@'use strict';--/**-- * Private AI on OpenVibe.Exchange (the plan of 2026-11-06, "Product lines and features", item 2):-- *-- * - the strict-privacy switch on a chat sends the private preset, and the answer's receipt carries the label;-- * - when the router refuses (no private offer can answer) the person sees its plain message, nothing else;-- * - a chat sealed with the person's Locker stores only the envelope: the database never sees a turn, the chat-- * list shows a neutral title, and the page never renders the conversation;-- * - the switch on the API page is the account's "default" preset in AI;-- * - one account never reads or writes another's sealed chat.-- */--const assert = require('assert');++/** Exchange's chat storage is retained for export, and its browser routes have moved to Chat. */++const assert = require('node:assert/strict');const { boot, check, done } = require('./helpers/boot');const { createFakeAi } = require('./helpers/ai');--const ENVELOPE = Buffer.alloc(96, 7).toString('base64');--const PLAINTEXT = 'a secret question about clocks';----function makeAi({ chat = null } = {}) {-- const base = createFakeAi();-- const answer = async (args) => {-- if (chat) return chat(args);-- const out = await base.chat(args);-- return {-- ...out,-- privacy: args.preset === 'private' ? 'private' : 'anonymized',-- privacy_detail: { label: args.preset === 'private' ? 'private' : 'anonymized', retention: args.preset === 'private' ? 'none' : 'limited', training: args.preset === 'private' ? false : null, known: true },-- preset: args.preset || null,-- };-- };-- const ai = createFakeAi({ chat: answer });++(async () => {++ const ai = createFakeAi();ai.presets = [];ai.presetCalls = [];ai.presetsFor = async () => ai.presets;@@@ -37,142 +14,24 @@ function makeAi({ chat = null } = {}) {ai.presets = on ? [{ name: 'default', scope: 'owner', policy: { data_policy: 'private', community: false } }] : [];return { on };};-- return ai;--}----(async () => {-- const ai = makeAi();const t = await boot({ ai });const alice = t.network.addUser('alice');-- const bob = t.network.addUser('bob');try {-- await check('the chat page carries the strict-privacy switch, off by default', async () => {-- const r = await t.get('/chat/new', { as: alice });-- assert.strictEqual(r.status, 200, r.text);-- assert.ok(r.text.includes('name="strict_privacy"') && r.text.includes('Strict privacy'), 'the switch is there');-- assert.ok(!/name="strict_privacy" value="1" checked/.test(r.text), 'off by default');-- });---- let strictChat = null;-- await check('strict privacy sends the private preset and the receipt carries the label', async () => {-- const r = await t.get('/chat/send', {-- as: alice,-- form: { text: 'Who keeps my prompt?', strict_privacy: '1', 'models_deepseek/deepseek-chat': '1' },-- });-- assert.strictEqual(r.status, 303, r.text);-- strictChat = r.headers.get('location');-- assert.strictEqual(ai.calls.length, 1);-- assert.strictEqual(ai.calls[0].preset, 'private', 'the run names the private preset');-- assert.strictEqual(ai.calls[0].subject, alice.subject);---- const chat = await t.ctx.chats.get(alice.subject, strictChat.split('/').pop());-- assert.strictEqual(chat.strict_privacy, true, 'the switch is remembered on the chat');---- const page = await t.get(strictChat, { as: alice });-- assert.strictEqual(page.status, 200);-- assert.ok(/name="strict_privacy" value="1" checked/.test(page.text), 'the switch stays on');-- assert.ok(page.text.includes('<dt>Privacy</dt>'), 'the receipt has the privacy row');-- assert.ok(page.text.includes('Preset'), 'and the preset it used');-- });---- await check('turning the switch off sends no preset', async () => {-- const before = ai.calls.length;-- const r = await t.get(`${strictChat}/send`, { as: alice, form: { text: 'And now?', 'models_deepseek/deepseek-chat': '1' } });-- assert.strictEqual(r.status, 303);-- assert.strictEqual(ai.calls.length, before + 1);-- assert.strictEqual(ai.calls[ai.calls.length - 1].preset, null);-- const chat = await t.ctx.chats.get(alice.subject, strictChat.split('/').pop());-- assert.strictEqual(chat.strict_privacy, false);++ await check('sealed chat browser routes redirect without writing an envelope', async () => {++ const r = await t.get('/chat/sealed/new', { as: alice, json: { envelope: Buffer.alloc(96, 7).toString('base64') } });++ assert.equal(r.status, 301);++ assert.equal(r.headers.get('location'), 'https://openvibe.chat/vibe');++ assert.equal((await t.ctx.chats.list(alice.subject)).length, 0);});---- const refusing = makeAi({-- chat: async () => {-- const err = new Error('Strict privacy is on: no offer that keeps nothing and trains on nothing can answer right now. Nothing was sent to a provider that keeps or trains on your prompt, and the router does not fall back to one.');-- err.code = 'policy.privacy_unavailable';-- err.status = 503;-- throw err;-- },-- });-- const rt = await boot({ ai: refusing });-- const carol = rt.network.addUser('carol');-- try {-- await check('a strict refusal is shown as its plain message, never an answer', async () => {-- const r = await rt.get('/chat/send', { as: carol, form: { text: 'hi', strict_privacy: '1' } });-- assert.strictEqual(r.status, 303, r.text);-- const page = await rt.get(r.headers.get('location'), { as: carol });-- assert.strictEqual(page.status, 200);-- assert.ok(page.text.includes('Strict privacy is on'), 'the router’s own words');-- assert.ok(page.text.includes('does not fall back'), 'and why it refused');-- assert.ok(!page.text.includes('An answer from'), 'no model answered');-- });-- } finally { await rt.close(); }---- let sealedId = null;-- await check('a sealed chat is created from an envelope alone', async () => {-- const r = await t.get('/chat/sealed/new', { json: { envelope: ENVELOPE }, as: alice });-- assert.strictEqual(r.status, 201, r.text);-- sealedId = r.json().id;-- assert.match(sealedId, /^exc_/);-- assert.strictEqual(r.json().url, `/chat/${sealedId}`);-- });---- await check('the chat list shows the sealed title, and the page never renders the conversation', async () => {-- const list = await t.get('/chat', { as: alice });-- assert.strictEqual(list.status, 303, 'the first chat opens');-- const page = await t.get(`/chat/${sealedId}`, { as: alice });-- assert.strictEqual(page.status, 200);-- assert.ok(page.text.includes('id="ex-sealed"') && page.text.includes('data-sealed-form'), 'the shell');-- assert.ok(page.text.includes('ov-locker.js'), 'the Locker loads');-- assert.ok(!page.text.includes(PLAINTEXT));-- const dump = await t.dbDump();-- assert.ok(!dump.includes(PLAINTEXT), 'the database holds no plaintext of a sealed chat');-- assert.ok(dump.includes(ENVELOPE), 'it holds the envelope');-- const chat = await t.ctx.chats.get(alice.subject, sealedId);-- assert.strictEqual(chat.sealed, true);-- assert.strictEqual(chat.title, 'Sealed chat', 'a neutral title until the owner opens it');-- });---- await check('the envelope is readable and replaceable only by its owner', async () => {-- const got = await t.get(`/chat/${sealedId}/sealed`, { as: alice, headers: { accept: 'application/json' } });-- assert.strictEqual(got.status, 200, got.text);-- assert.strictEqual(got.json().envelope, ENVELOPE);-- const next = Buffer.alloc(120, 9).toString('base64');-- const put = await t.get(`/chat/${sealedId}/sealed`, { as: alice, method: 'PUT', json: { envelope: next } });-- assert.strictEqual(put.status, 200, put.text);-- assert.strictEqual((await t.get(`/chat/${sealedId}/sealed`, { as: alice })).json().envelope, next);-- // Another account: nothing, and it can neither read nor write it.-- assert.strictEqual((await t.get(`/chat/${sealedId}`, { as: bob })).status, 404);-- assert.strictEqual((await t.get(`/chat/${sealedId}/sealed`, { as: bob })).status, 404);-- assert.strictEqual((await t.get(`/chat/${sealedId}/sealed`, { as: bob, method: 'PUT', json: { envelope: ENVELOPE } })).status, 404);-- });---- await check('a sealed send runs the model and stores nothing', async () => {-- const before = ai.calls.length;-- const messages = [{ role: 'user', content: PLAINTEXT }];-- const r = await t.get(`/chat/${sealedId}/sealed/send`, { as: alice, json: { model: 'deepseek/deepseek-chat@v1', messages, strict_privacy: true } });-- assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(ai.calls.length, before + 1);-- assert.strictEqual(ai.calls[ai.calls.length - 1].preset, 'private');-- assert.deepStrictEqual(ai.calls[ai.calls.length - 1].messages, messages, 'the turn goes to the model');-- assert.ok(r.json().answer.content.includes('An answer from'));-- // Nothing of that turn is in the database: no message row, no plaintext anywhere.-- assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_chat_messages WHERE chat_id = $1', [sealedId])), 0);-- const dump = await t.dbDump();-- assert.ok(!dump.includes(PLAINTEXT));-- });---- await check('the strict switch on the API page is the account’s default preset in AI', async () => {++ await check('the strict switch on the API page remains the account default preset in AI', async () => {const page = await t.get('/api', { as: alice });-- assert.strictEqual(page.status, 200);-- assert.ok(page.text.includes('Strict privacy on my keys') && page.text.includes('action="/privacy/keys"'), 'the switch');-- const off = await t.get('/privacy/keys', { as: alice, form: { strict: '1' } });-- assert.strictEqual(off.status, 200);-- assert.deepStrictEqual(ai.presetCalls[ai.presetCalls.length - 1], { subject: alice.subject, on: true });-- const on = await t.get('/api', { as: alice });-- assert.ok(/name="strict" value="1" checked/.test(on.text), 'the switch reads back on');++ assert.equal(page.status, 200);++ assert.ok(page.text.includes('Strict privacy on my keys'));++ const on = await t.get('/privacy/keys', { as: alice, form: { strict: '1' } });++ assert.equal(on.status, 200);++ assert.deepEqual(ai.presetCalls.at(-1), { subject: alice.subject, on: true });await t.get('/privacy/keys', { as: alice, method: 'POST', form: {} });-- assert.deepStrictEqual(ai.presetCalls[ai.presetCalls.length - 1], { subject: alice.subject, on: false });++ assert.deepEqual(ai.presetCalls.at(-1), { subject: alice.subject, on: false });});-- } finally { await t.close(); }-- done();--})();++ } finally { await t.close(); done(); }++})().catch((err) => { console.error(err); process.exitCode = 1; });ddeleted file mode 100644@@@ -1,95 +0,0 @@--'use strict';--/**-- * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]): the page-- * carries markup only, ov-share.js draws the gate when it loads, signed out it offers Sign in, and a signed-in-- * person who is not on the list is offered Ask for access — with no console error and no CSP refusal.-- *-- * Skipped (exit 0) without Chrome, or with OV_SKIP_BROWSER=1. The page is served by this repo's own app on a-- * temporary database, so the CSP headers the browser sees are Exchange's.-- */--const assert = require('assert');--const fs = require('fs');--const { boot, check, done } = require('./helpers/boot');--const { createFakeAi } = require('./helpers/ai');----let playwright = null;--try { playwright = require('playwright'); } catch {-- try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }--}--const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';--if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {-- console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');-- process.exit(0);--}----/** Console errors that mean the gate did not draw, rather than an incidental failed favicon. */--const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;----(async () => {-- const ai = createFakeAi();-- const t = await boot({ ai });-- const alice = t.network.addUser('alice');-- const carol = t.network.addUser('carol');-- const cookieOf = (user) => {-- const [name, ...rest] = t.signIn(user).split('=');-- return { name, value: rest.join('='), url: t.base };-- };---- let browser;-- const errors = [];-- const watch = (page) => {-- page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });-- page.on('pageerror', (e) => errors.push(String(e)));-- };-- const settled = async (page) => {-- await page.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });-- return await page.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());-- };---- try {-- let chatId;-- await check('a chat shared with Specific people, with nobody on the list yet', async () => {-- const made = await t.get('/chat/send', { as: alice, form: { text: 'What is a semaphore?', 'models_deepseek/deepseek-chat': '1' } });-- assert.strictEqual(made.status, 303, made.text);-- chatId = made.headers.get('location').split('/').pop();-- await t.ctx.sharing.setAudience({ subject: alice.subject }, 'chat', chatId, 'people');-- const mine = await t.get(`/chat/${chatId}`, { as: alice });-- assert.strictEqual(mine.status, 200, mine.text);-- });---- await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {-- browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });-- const ctx = await browser.newContext();-- const page = await ctx.newPage();-- watch(page);-- const res = await page.goto(`${t.base}/chat/${chatId}`, { waitUntil: 'load', timeout: 15000 });-- assert.strictEqual(res.status(), 404, 'the gate answers 404, never the chat');-- const text = await settled(page);-- assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);-- const href = await page.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));-- assert.match(href, /^\/auth\/login\?next=/);-- await ctx.close();-- });---- await check('headless Chrome: signed in as somebody not on the list, Ask for access', async () => {-- const ctx = await browser.newContext();-- await ctx.addCookies([cookieOf(carol)]);-- const page = await ctx.newPage();-- watch(page);-- await page.goto(`${t.base}/chat/${chatId}`, { waitUntil: 'load', timeout: 15000 });-- const text = await settled(page);-- assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);-- assert.ok(/@carol/.test(text), 'the gate names the account it is signed in as');-- await ctx.close();-- });---- await check('headless Chrome: no console error and no CSP refusal on the gate page', async () => {-- const refused = errors.filter((e) => REFUSED.test(e));-- assert.deepStrictEqual(refused, [], `the gate page was refused: ${refused.join(' | ')}`);-- });-- } finally {-- if (browser) await browser.close().catch(() => {});-- await t.close();-- done();-- }--})();@@@ -42,68 +42,6 @@ const { createFakeBench } = require('./helpers/bench');'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['exchange', kind, id]);try {-- let chatId;-- await check('a new chat is Only me, and its page carries the one Share sheet', async () => {-- const made = await t.get('/chat/send', { as: alice, form: { text: 'What is a semaphore?', 'models_deepseek/deepseek-chat': '1' } });-- assert.strictEqual(made.status, 303, made.text);-- chatId = made.headers.get('location').split('/').pop();-- assert.deepStrictEqual(await audienceRow('chat', chatId), { audience: 'private' });-- const owner = await t.get(`/chat/${chatId}`, { as: alice });-- assert.strictEqual(owner.status, 200, owner.text);-- assert.ok(owner.text.includes('data-ov-share-sheet') && owner.text.includes('data-kind="chat"'), 'the Share sheet is mounted');-- assert.ok(owner.text.includes('ov-share.js'), 'the sheet loads with the page');-- const other = await t.get(`/chat/${chatId}`, { as: bob });-- assert.strictEqual(other.status, 404, 'another account is told nothing');-- assert.ok(!other.text.includes('What is a semaphore?'), 'never the title');-- const anon = await t.get(`/chat/${chatId}`);-- assert.strictEqual(anon.status, 404);-- });---- await check('Anyone with the link reads the conversation, and it has no composer', async () => {-- const saved = await setShare('chat', chatId, { audience: 'link' }, alice);-- assert.strictEqual(saved.status, 200, saved.text);-- assert.strictEqual(saved.json().audience, 'link');-- const anon = await t.get(`/chat/${chatId}`);-- assert.strictEqual(anon.status, 200, anon.text);-- assert.ok(anon.text.includes('What is a semaphore?'));-- assert.ok(anon.text.includes('An answer from deepseek/deepseek-chat@v1.'));-- assert.ok(!anon.text.includes('ovt-composer'), 'a reader cannot write into somebody else\'s chat');-- assert.ok(!anon.text.includes('name="text"'), 'no send box');-- });---- await check('Specific people: the person on the list reads it, another gets the gate and no title', async () => {-- const saved = await setShare('chat', chatId, { audience: 'people', members: [{ name: 'bob', role: 'viewer' }] }, alice);-- assert.strictEqual(saved.status, 200, saved.text);-- assert.strictEqual(saved.json().audience, 'people');-- const member = await t.get(`/chat/${chatId}`, { as: bob });-- assert.strictEqual(member.status, 200, member.text);-- assert.ok(member.text.includes('What is a semaphore?'));-- const stranger = await t.get(`/chat/${chatId}`, { as: carol });-- assert.strictEqual(stranger.status, 404);-- assert.ok(stranger.text.includes('shared with specific people'));-- assert.ok(!stranger.text.includes('What is a semaphore?'), 'the gate never reveals the title');-- const anon = await t.get(`/chat/${chatId}`);-- assert.strictEqual(anon.status, 404);-- assert.ok(anon.text.includes('shared with specific people'));-- });---- await check('a sealed chat has no Share sheet and cannot leave Only me', async () => {-- const envelope = Buffer.alloc(48, 7).toString('base64');-- const made = await t.get('/chat/sealed/new', { as: alice, json: { envelope } });-- assert.strictEqual(made.status, 201, made.text);-- const sealedId = made.json().id;-- const page = await t.get(`/chat/${sealedId}`, { as: alice });-- assert.strictEqual(page.status, 200);-- assert.ok(page.text.includes('Only me, always'));-- assert.ok(!page.text.includes('data-ov-share-sheet'), 'no Share sheet on a sealed chat');-- const refused = await setShare('chat', sealedId, { audience: 'public' }, alice);-- assert.strictEqual(refused.status, 400, refused.text);-- assert.strictEqual(refused.json().code, 'share.invalid');-- assert.deepStrictEqual(await audienceRow('chat', sealedId), { audience: 'private' });-- const other = await t.get(`/chat/${sealedId}`, { as: bob });-- assert.strictEqual(other.status, 404);-- });--let roundId;await check('a new Arena round is Only me, and a shared round names both models with no vote', async () => {const asked = await t.get('/arena/ask', { as: alice, form: { prompt: 'Write a function that reads a CSV and sums a column' } });@@@ -121,20 +59,6 @@ const { createFakeBench } = require('./helpers/bench');assert.ok(!seen.text.includes('name="outcome"'), 'a reader does not vote on somebody else\'s round');});-- await check('the person\'s default applies to the next chat, and "apply to all" moves the existing ones', async () => {-- const saved = await t.get('/chat/defaults', { as: alice, form: { audience: 'public', apply_all: '1' } });-- assert.strictEqual(saved.status, 303, saved.text);-- assert.deepStrictEqual(await audienceRow('chat', chatId), { audience: 'public' });-- const next = await t.get('/chat/send', { as: alice, form: { text: 'A second question', 'models_deepseek/deepseek-chat': '1' } });-- const nextId = next.headers.get('location').split('/').pop();-- assert.deepStrictEqual(await audienceRow('chat', nextId), { audience: 'public' });-- const anon = await t.get(`/chat/${nextId}`);-- assert.strictEqual(anon.status, 200);-- assert.ok(anon.text.includes('A second question'));-- const start = await t.get('/chat/new', { as: alice });-- assert.ok(start.text.includes('New chats'), 'the default is on the product page');-- });--await check('characters: Only me while being written, Specific people to show one, the default for new cards', async () => {const FORM = { name: 'Ines', description: 'A cartographer', personality: 'patient', scenario: 'a map room', first_message: 'Where to?', rating: 'everyone' };const made = await t.get('/companions/new', { as: alice, form: FORM });
@@ -17,7 +17,8 @@ for a person — and keeps only two things of its own: a person's chats and thei
@@ -17,7 +17,8 @@ for a person — and keeps only two things of its own: a person's chats and thei
|---|---|
|---|---|
| `/` | "Every AI model, one account": search, popular now, cheapest per million, uncensored, community machines, the endpoint, AI in OpenVibe. Every number is live. |
| `/` | "Every AI model, one account": search, popular now, cheapest per million, uncensored, community machines, the endpoint, AI in OpenVibe. Every number is live. |
| `/models`, `/m/<org>/<model>` | Every model and every offer that serves it: price in, out and cached per million, measured p50/p95, uptime, data policy, a **Try it** box, curl and the SDK line. |
| `/models`, `/m/<org>/<model>` | Every model and every offer that serves it: price in, out and cached per million, measured p50/p95, uptime, data policy, a **Try it** box, curl and the SDK line. |
-| `/chat` | One model, or up to three side by side; system prompt, temperature, history in the account. The shared conversation UI (`openvibe-shared/thread`, `ov-thread.js`). |
+| `/chat` and `/chat/*` | 301 to `https://openvibe.chat/vibe`, carrying `?models=` and mapping old `@vN` model ids. Model pages' Try it links go there directly. |
+
| `/api` | The endpoint, model ids (`deepseek/deepseek-chat`, `openvibe/auto`), keys from Network, aggregate usage, and the link to AI's own provider-keys page (`/you`, a session page). |
| `/api` | The endpoint, model ids (`deepseek/deepseek-chat`, `openvibe/auto`), keys from Network, aggregate usage, and the link to AI's own provider-keys page (`/you`, a session page). |
| `/start` | Start building: one key, one endpoint, the OpenAI/Anthropic samples, four steps, AI's six operations, and the sandbox and production limits. |
| `/start` | Start building: one key, one endpoint, the OpenAI/Anthropic samples, four steps, AI's six operations, and the sandbox and production limits. |
| `/why` | Why this model: the routing rule and a signed-in demo that shows a real run's explain — the objective, the selected offer, and every candidate with its cost, latency and reason. |
| `/why` | Why this model: the routing rule and a signed-in demo that shows a real run's explain — the objective, the selected offer, and every candidate with its cost, latency and reason. |
@@ -27,6 +28,8 @@ for a person — and keeps only two things of its own: a person's chats and thei
@@ -27,6 +28,8 @@ for a person — and keeps only two things of its own: a person's chats and thei
| `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. |
| `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. |
| `/status` | Uptime per offer, the model changelog and incidents. |
| `/status` | Uptime per offer, the model changelog and incidents. |
+The old chat rows remain for a person's one-time import into Chat. `GET /internal/exchange/chat/export` requires Chat's service token with `exchange.chat.export` and the authenticated person's `x-ov-subject`; it returns only that person's unsealed chats.
+
Every page is server-rendered with openvibe-shared/kit and complete without JavaScript. A page rendered for a
Every page is server-rendered with openvibe-shared/kit and complete without JavaScript. A page rendered for a
signed-in person is `private, no-store`; a public page is `public, max-age=60`.
signed-in person is `private, no-store`; a public page is `public, max-age=60`.
@@ -214,6 +214,31 @@ async function createApp(opts = {}) {
@@ -214,6 +214,31 @@ async function createApp(opts = {}) {
app.locals.sharingTimer = timer;
app.locals.sharingTimer = timer;
}
}
+ // Chat imports only the signed-in person's unsealed history. The service token must carry
+ // the narrow export capability; Chat supplies the subject it just authenticated.
+ app.get('/internal/exchange/chat/export', principal.middleware, async (req, res) => {
+ const p = req.principal;
+ const subject = String(req.get('x-ov-subject') || '');
+ if (!p || p.kind !== 'app' || p.claims.sub !== 'svc:chat' || !p.claims.cap.includes('exchange.chat.export') || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject))
+ return res.status(403).json({ error: 'Chat export is unavailable to this caller' });
+ const list = (await chats.list(subject, 200)).filter((c) => !c.sealed);
+ const exported = await Promise.all(list.map(async (c) => {
+ const full = await chats.get(subject, c.id);
+ return { id: c.id, title: c.title, models: c.models, created_at: full.created_at, updated_at: c.updated_at,
+ messages: (await chats.messages(subject, c.id)).map((m) => ({ role: m.role, model: m.model,
+ content: m.content, created_at: m.created_at, meta: m.meta })) };
+ }));
+ return res.set('Cache-Control', 'private, no-store').json({ count: exported.length, chats: exported });
+ });
+
+ // Talking to models moved to Chat. Carry an old model selection across the 301.
+ app.use(/^\/chat(?:\/.*)?$/, (req, res) => {
+ const ids = String(req.query.models || '').split(',').map((x) => x.replace(/@v[0-9]+$/i, ''))
+ .filter((x) => x !== 'openvibe/auto' && /^[a-z0-9._/@:-]{2,256}$/i.test(x)).slice(0, 3);
+ const suffix = ids.length ? `?models=${encodeURIComponent(ids.join(','))}` : '';
+ return res.redirect(301, `https://openvibe.chat/vibe${suffix}`);
+ });
+
// ── Exchange v1's JSON API: gone ────────────────────────
// ── Exchange v1's JSON API: gone ────────────────────────
app.use('/api/v1', (req, res) => {
app.use('/api/v1', (req, res) => {
res.set('Cache-Control', 'public, max-age=86400');
res.set('Cache-Control', 'public, max-age=86400');
@@ -148,26 +148,8 @@ function createPageRoutes(ctx) {
@@ -148,26 +148,8 @@ function createPageRoutes(ctx) {
}
}
r.get('/m/:org/:model', (req, res) => renderModel(req, res));
r.get('/m/:org/:model', (req, res) => renderModel(req, res));
- r.post('/m/:org/:model/try', async (req, res) => {
- if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to try it', text: 'A try is one run on your own free allowance.', next: `/m/${req.params.org}/${req.params.model}` });
- if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
- const text = String((req.body && req.body.text) || '').slice(0, config.chat.maxChars);
- if (!text.trim()) return renderModel(req, res, { problem: 'Write something to send.', status: 422 });
- const [catalog, community] = await Promise.all([catalogOf(), communityOffers()]);
- const model = await modelFrom(catalog, req.params.org, req.params.model);
- if (!model) return renderModel(req, res);
- if (model.uncensored && !(await adult.confirmed(owner(req)))) {
- return page(req, res, { title: model.name, index: false, body: raw(views.modelPage({ model, viewer: req.viewer, problem: 'Confirm you are 18 or over first.', adult: false, community })) }, 403);
- }
- const best = model.offers.find((o) => o.id) || model.offers[0];
- const modelId = (best && best.id) || 'openvibe/auto';
- try {
- const out = await ai.chat({ model: modelId, messages: [{ role: 'user', content: text }], subject: owner(req) });
- return renderModel(req, res, { result: out, text });
- } catch (err) {
- return renderModel(req, res, { problem: err.message || 'The model did not answer.', text, status: err.status === 429 ? 429 : 502 });
- }
- });
+ r.post('/m/:org/:model/try', (req, res) => res.redirect(301,
+ `https://openvibe.chat/vibe?models=${encodeURIComponent(`${req.params.org}/${req.params.model}`)}`));
// ── Chat ─────────────────────────────────────────────────
// ── Chat ─────────────────────────────────────────────────
async function chatCatalog() {
async function chatCatalog() {
@@ -22,7 +22,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -22,7 +22,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// "OpenVibe..."). Icons are the navbar's own glyphs; the brand is the way home.
// "OpenVibe..."). Icons are the navbar's own glyphs; the brand is the way home.
const NAV = [
const NAV = [
{ label: 'Models', href: '/models', icon: 'fa-microchip' },
{ label: 'Models', href: '/models', icon: 'fa-microchip' },
- { label: 'Chat', href: '/chat', icon: 'fa-comments' },
+ { label: 'Chat', href: 'https://openvibe.chat/vibe', icon: 'fa-comments' },
{ label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
{ label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line' },
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line' },
{ label: 'Start building', href: '/start', icon: 'fa-bolt' },
{ label: 'Start building', href: '/start', icon: 'fa-bolt' },
@@ -11,6 +11,8 @@ const { esc } = require('../render/html');
@@ -11,6 +11,8 @@ const { esc } = require('../render/html');
const { mark, product, productMark } = require('./marks');
const { mark, product, productMark } = require('./marks');
const ENDPOINT = 'https://api.openvibe.exchange/v1/chat/completions';
const ENDPOINT = 'https://api.openvibe.exchange/v1/chat/completions';
+const CHAT = 'https://openvibe.chat/vibe';
+const chatModel = (m) => `${CHAT}?models=${encodeURIComponent(m.slug)}`;
const money = (n, free = false) => (free ? 'free' : n == null ? '–' : n === 0 ? '$0' : `$${Number(n) < 0.1 ? Number(n).toPrecision(2) : Number(n).toFixed(2)}`);
const money = (n, free = false) => (free ? 'free' : n == null ? '–' : n === 0 ? '$0' : `$${Number(n) < 0.1 ? Number(n).toPrecision(2) : Number(n).toFixed(2)}`);
const inOut = (price) => (price ? `${money(price.in)} in · ${money(price.out)} out` : 'no per-token price');
const inOut = (price) => (price ? `${money(price.in)} in · ${money(price.out)} out` : 'no per-token price');
@@ -151,7 +153,7 @@ function modelRow(m, { withTry = true } = {}) {
@@ -151,7 +153,7 @@ function modelRow(m, { withTry = true } = {}) {
if (pv) bits.push(pv.short);
if (pv) bits.push(pv.short);
return `<li class="ex-mrow">
return `<li class="ex-mrow">
<a class="ex-mrow-main" href="${hrefOf(m)}">${mark(familyLabel(m))}<span class="ex-mrow-text"><span class="ex-mrow-title"><b>${esc(m.name)}</b>${badgesOf(m, best)}</span><small>${bits.map((b) => esc(b)).join(' · ')}</small></span></a>
<a class="ex-mrow-main" href="${hrefOf(m)}">${mark(familyLabel(m))}<span class="ex-mrow-text"><span class="ex-mrow-title"><b>${esc(m.name)}</b>${badgesOf(m, best)}</span><small>${bits.map((b) => esc(b)).join(' · ')}</small></span></a>
-${withTry ? `<a class="ex-try-link" href="${hrefOf(m)}#try">Try it</a>` : ''}
+${withTry ? `<a class="ex-try-link" href="${chatModel(m)}">Try it</a>` : ''}
</li>`;
</li>`;
}
}
@@ -239,7 +241,7 @@ function previewCard(preview) {
@@ -239,7 +241,7 @@ function previewCard(preview) {
<p class="ex-preview-tag">A side-by-side answer <span>example words · live prices</span></p>
<p class="ex-preview-tag">A side-by-side answer <span>example words · live prices</span></p>
<p class="ex-preview-q">“Summarize this contract in three bullets.”</p>
<p class="ex-preview-q">“Summarize this contract in three bullets.”</p>
<div class="ex-preview-grid">${cells}</div>
<div class="ex-preview-grid">${cells}</div>
-<p class="ex-more"><a href="/chat">Open the real chat →</a></p>
+<p class="ex-more"><a href="https://openvibe.chat/vibe">Open the real chat →</a></p>
</aside>`;
</aside>`;
}
}
@@ -288,7 +290,7 @@ function modelsPage({ catalog, q = '', family = '', sort = '', community = null
@@ -288,7 +290,7 @@ function modelsPage({ catalog, q = '', family = '', sort = '', community = null
const familyLinks = families.slice(0, 10).map((f) => `<a class="ex-chip${f === family ? ' is-on' : ''}" href="/models?family=${encodeURIComponent(f)}">${esc(f)}</a>`).join('');
const familyLinks = families.slice(0, 10).map((f) => `<a class="ex-chip${f === family ? ' is-on' : ''}" href="/models?family=${encodeURIComponent(f)}">${esc(f)}</a>`).join('');
return `<div class="ex-wrap ex-page">
return `<div class="ex-wrap ex-page">
-${kit.head({ title: 'Models', sub: `${models.length} model${models.length === 1 ? '' : 's'} · ${offerCount(models)} offers, live`, actions: [kit.button({ href: '/chat', label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
+${kit.head({ title: 'Models', sub: `${models.length} model${models.length === 1 ? '' : 's'} · ${offerCount(models)} offers, live`, actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model or a provider', value: q, icon: 'search' })}
${kit.find({ action: '/models', name: 'q', placeholder: 'Find a model or a provider', value: q, icon: 'search' })}
<div class="ex-chips" role="group" aria-label="Sort">${links}</div>
<div class="ex-chips" role="group" aria-label="Sort">${links}</div>
${families.length ? `<div class="ex-chips" role="group" aria-label="Family">${familyLinks}</div>` : ''}
${families.length ? `<div class="ex-chips" role="group" aria-label="Family">${familyLinks}</div>` : ''}
@@ -336,7 +338,7 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
@@ -336,7 +338,7 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
uptime: o.uptime == null ? '–' : pct(o.uptime),
uptime: o.uptime == null ? '–' : pct(o.uptime),
privacy: `${p ? kit.badge(p.label, p.kind) : ''}${refuses ? `<br><small>${esc(refuses)}</small>` : ''}` || '–',
privacy: `${p ? kit.badge(p.label, p.kind) : ''}${refuses ? `<br><small>${esc(refuses)}</small>` : ''}` || '–',
policy: policyText(o.data_policy),
policy: policyText(o.data_policy),
- try: '<a class="ex-mini" href="#try">Try it</a>',
+ try: `<a class="ex-mini" href="${chatModel(model)}">Try it</a>`,
};
};
});
});
@@ -351,7 +353,7 @@ ${stats.length ? kit.stats(stats) : ''}
@@ -351,7 +353,7 @@ ${stats.length ? kit.stats(stats) : ''}
${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}
${model.description ? `<p class="ex-prose">${esc(model.description)}</p>` : ''}
${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}
${panel({ title: 'Every offer that serves it', note: 'Price in, out and cached per million tokens; speed and uptime from the last seven days of real requests; the privacy label and the provider’s own data policy.', id: 'offers', body: kit.table({ columns, rows, label: `Offers for ${model.name}`, empty: 'No live offer serves this model right now.' }) })}
${panel({ title: 'Presets', note: 'A preset fixes the router’s floor, ceiling and policy, and you name it as the model.', id: 'presets', body: presetsBox() })}
${panel({ title: 'Presets', note: 'A preset fixes the router’s floor, ceiling and policy, and you name it as the model.', id: 'presets', body: presetsBox() })}
-${panel({ title: 'Try it', id: 'try', body: tryBox(model, viewer, { result, problem, text, adult }) })}
+${panel({ title: 'Try it', id: 'try', body: kit.button({ href: chatModel(model), label: `Chat with ${model.name}` }) })}
${panel({ title: 'Use it from code', id: 'code', body: `${kit.snippet({ code, id: 'model-curl' })}
${panel({ title: 'Use it from code', id: 'code', body: `${kit.snippet({ code, id: 'model-curl' })}
${kit.snippet({ code: sdk, id: 'model-sdk' })}
${kit.snippet({ code: sdk, id: 'model-sdk' })}
<p class="ex-more">The endpoint, keys and usage: <a href="/api">the API</a>.</p>` })}
<p class="ex-more">The endpoint, keys and usage: <a href="/api">the API</a>.</p>` })}
@@ -383,7 +385,7 @@ function presetsBox() {
@@ -383,7 +385,7 @@ function presetsBox() {
function sealedChat({ chat = null, models = [], networkUrl = '', subject = '' }) {
function sealedChat({ chat = null, models = [], networkUrl = '', subject = '' }) {
const options = models.slice(0, 40).map((m) => `<option value="${esc(m.slug)}">${esc(m.name)}${m.uncensored ? ' · 18+' : ''}</option>`).join('');
const options = models.slice(0, 40).map((m) => `<option value="${esc(m.slug)}">${esc(m.name)}${m.uncensored ? ' · 18+' : ''}</option>`).join('');
return `<div class="ex-wrap ex-page ex-sealed" id="ex-sealed" data-chat="${chat ? esc(chat.id) : ''}" data-subject="${esc(subject)}" data-network="${esc(networkUrl)}">
return `<div class="ex-wrap ex-page ex-sealed" id="ex-sealed" data-chat="${chat ? esc(chat.id) : ''}" data-subject="${esc(subject)}" data-network="${esc(networkUrl)}">
-${kit.head({ title: chat ? 'A sealed chat' : 'A new sealed chat', sub: 'Your history is sealed with your Locker.', back: { href: '/chat', label: 'Chat' }, actions: [kit.button({ href: '/chat/new', label: 'A normal chat', kind: 'quiet' })] })}
+${kit.head({ title: chat ? 'A sealed chat' : 'A new sealed chat', sub: 'Your history is sealed with your Locker.', back: { href: CHAT, label: 'Chat' }, actions: [kit.button({ href: CHAT, label: 'A normal chat', kind: 'quiet' })] })}
<noscript><p class="ex-prose">A sealed chat needs JavaScript: the sealing and the opening happen in your browser, and nowhere else.</p></noscript>
<noscript><p class="ex-prose">A sealed chat needs JavaScript: the sealing and the opening happen in your browser, and nowhere else.</p></noscript>
<div class="ex-sealed-card">
<div class="ex-sealed-card">
<p class="ex-sealed-title" data-sealed-title hidden></p>
<p class="ex-sealed-title" data-sealed-title hidden></p>
@@ -471,7 +473,7 @@ ${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.no
@@ -471,7 +473,7 @@ ${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.no
<p>${kit.button({ label: 'Save the switch', type: 'submit', kind: 'quiet' })}</p>
<p>${kit.button({ label: 'Save the switch', type: 'submit', kind: 'quiet' })}</p>
</form>`);
</form>`);
return `<div class="ex-wrap ex-page">
return `<div class="ex-wrap ex-page">
-${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: '/chat', label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
+${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}
${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}
${panel({ title: 'One endpoint', body: `
${panel({ title: 'One endpoint', body: `
<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>
<p class="ex-prose"><code>${ENDPOINT}</code> speaks OpenAI's Chat Completions; <code>https://api.openvibe.exchange/v1/messages</code> speaks Anthropic's Messages API, so Claude Code works by pointing <code>ANTHROPIC_BASE_URL</code> at it.</p>
@@ -735,7 +737,7 @@ ${kit.head({ title: 'Uncensored', sub: 'Abliterated models, for adults. The same
@@ -735,7 +737,7 @@ ${kit.head({ title: 'Uncensored', sub: 'Abliterated models, for adults. The same
${gate || ''}
${gate || ''}
${panel({ title: 'The floor on every offer, uncensored or not', id: 'floor', body: floorText(true) })}
${panel({ title: 'The floor on every offer, uncensored or not', id: 'floor', body: floorText(true) })}
${adult ? panel({ title: 'Uncensored models', id: 'models', body: modelList(models, { empty: 'No uncensored model is live right now. Featherless and Refuseless offers appear here when their keys are on.' }) }) : ''}
${adult ? panel({ title: 'Uncensored models', id: 'models', body: modelList(models, { empty: 'No uncensored model is live right now. Featherless and Refuseless offers appear here when their keys are on.' }) }) : ''}
-${panel({ title: 'How to use it', body: '<p class="ex-prose">Pick an uncensored model in <a href="/chat">chat</a>, or send its exact id to the API. Your 18+ confirmation sits on your account, so it follows you here; the floor applies to every request on this tier, and a breach ends the account.</p>' })}
+${panel({ title: 'How to use it', body: '<p class="ex-prose">Pick an uncensored model in <a href="https://openvibe.chat/vibe">chat</a>, or send its exact id to the API. Your 18+ confirmation sits on your account, so it follows you here; the floor applies to every request on this tier, and a breach ends the account.</p>' })}
${companionsPolicy()}
${companionsPolicy()}
${changelogPanel()}
${changelogPanel()}
</div>`;
</div>`;
@@ -1,124 +1,51 @@
@@ -1,124 +1,51 @@
'use strict';
'use strict';
-/**
- * /chat on the shared conversation UI: one model or three side by side, a system prompt and a temperature, the
- * history in the person's account, the daily allowance, the 18+ gate for an uncensored model, and one account's
- * chat never readable by another.
- */
-const assert = require('assert');
+/** Exchange's former chat redirects; Chat alone can export a signed-in person's history. */
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+const { serviceAuth } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { createFakeAi } = require('./helpers/ai');
const { createFakeAi } = require('./helpers/ai');
(async () => {
(async () => {
- const ai = createFakeAi();
- const t = await boot({ ai });
+ const t = await boot({ ai: createFakeAi() });
const alice = t.network.addUser('alice');
const alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
const bob = t.network.addUser('bob');
+ const token = (caps) => {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: t.network.issuer(), sub: 'svc:chat', actor_type: 'service',
+ aud: ['openvibe.exchange'], cap: caps, iat: now, exp: now + 300, jti: crypto.randomUUID() }, t.network.privatePem);
+ };
try {
try {
- const confirmed = await t.get('/uncensored/confirm', { as: alice, form: { adult: 'yes' } });
- assert.strictEqual(confirmed.status, 303, 'alice confirms 18+ for the comparison');
-
- await check('signed out, chat asks for a sign-in', async () => {
- const r = await t.get('/chat');
- assert.strictEqual(r.status, 401);
- assert.ok(r.text.includes('Sign in with OpenVibe'));
- });
-
- await check('the start page is the shared conversation UI with model chips', async () => {
- const r = await t.get('/chat/new', { as: alice });
- assert.strictEqual(r.status, 200, r.text);
- assert.ok(r.text.includes('/shared/thread.css') && r.text.includes('/shared/ov-thread.js'));
- assert.ok(r.text.includes('ovt-composer') && r.text.includes('ovk-sidebar'));
- assert.ok(r.text.includes('DeepSeek Chat') && r.text.includes('Llama 3 8B abliterated'), 'the model chips');
- assert.ok(r.text.includes('System prompt') && r.text.includes('Temperature'));
- });
-
- let chatUrl = null;
- await check('a message runs the chosen models side by side and stores the history', async () => {
- const r = await t.get('/chat/send', {
- as: alice,
- form: {
- text: 'What is a mutex?',
- 'models_deepseek/deepseek-chat': '1',
- 'models_meta-llama/llama-3-8b-abliterated': '1',
- system: 'Be brief.',
- temperature: '0.4',
- },
- });
- assert.strictEqual(r.status, 303, r.text);
- chatUrl = r.headers.get('location');
- assert.match(chatUrl, /^\/chat\/exc_/);
- assert.strictEqual(ai.calls.length, 2, 'one run per model');
- assert.deepStrictEqual(ai.calls.map((c) => c.model).sort(), ['deepseek/deepseek-chat@v1', 'openvibe/auto'], 'the exact id where one exists');
- assert.ok(ai.calls.every((c) => c.subject === alice.subject), 'each run is on the person’s behalf');
- assert.strictEqual(ai.calls[0].temperature, 0.4);
- assert.strictEqual(ai.calls[0].messages[0].role, 'system');
-
- const page = await t.get(chatUrl, { as: alice });
- assert.strictEqual(page.status, 200);
- assert.ok(page.text.includes('What is a mutex?'));
- assert.ok(page.text.includes('An answer from deepseek/deepseek-chat@v1.'));
- assert.ok(page.text.includes('An answer from openvibe/auto.'));
- assert.match(page.headers.get('cache-control'), /private|no-store/);
- assert.ok(page.text.includes('ex-answer-grid') && page.text.includes('data-count="2"'), 'the two answers sit in one round');
- assert.ok(page.text.includes('ex-receipt') && page.text.includes('Why this offer'), 'a folded receipt under each answer');
- assert.ok(page.text.includes('$0.000120') && page.text.includes('420 ms'), 'the receipt shows the price and latency');
- assert.ok(page.text.includes('ex-mk--deepseek') && page.text.includes('ex-mk--llama'), 'answers carry family marks');
- assert.ok(!page.text.includes('class="ovt-avatar"'), 'no stray empty avatar above an answer');
- });
-
- await check('the next message carries the history, and a second model’s answers never bleed across', async () => {
- const before = ai.calls.length;
- const r = await t.get(`${chatUrl}/send`, {
- as: alice,
- form: { text: 'And a semaphore?', 'models_deepseek/deepseek-chat': '1', 'models_meta-llama/llama-3-8b-abliterated': '1' },
- });
- assert.strictEqual(r.status, 303);
- const fresh = ai.calls.slice(before);
- assert.strictEqual(fresh.length, 2);
- const deep = fresh.find((c) => c.model === 'deepseek/deepseek-chat@v1');
- assert.deepStrictEqual(deep.messages.map((m) => m.role), ['user', 'assistant', 'user'], 'its own answer is in the history');
- assert.strictEqual(deep.messages[1].content, 'An answer from deepseek/deepseek-chat@v1.');
- });
-
- await check('another account cannot read the chat', async () => {
- assert.strictEqual((await t.get(chatUrl, { as: bob })).status, 404);
- assert.strictEqual((await t.get('/chat', { as: bob })).status, 200, 'bob has his own start page');
- });
-
- await check('an uncensored model needs the 18+ confirmation first', async () => {
- const before = ai.calls.length;
- const r = await t.get('/chat/send', { as: bob, form: { text: 'Hello', 'models_meta-llama/llama-3-8b-abliterated': '1' } });
- assert.strictEqual(r.status, 403);
- assert.ok(r.text.includes('18 or over'));
- assert.strictEqual(ai.calls.length, before, 'no run happened');
- const confirm = await t.get('/uncensored/confirm', { as: bob, form: { adult: 'yes' } });
- assert.strictEqual(confirm.status, 303);
- const ok = await t.get('/chat/send', { as: bob, form: { text: 'Hello', 'models_meta-llama/llama-3-8b-abliterated': '1' } });
- assert.strictEqual(ok.status, 303);
- });
-
- await check('a cross-site send is refused', async () => {
- const r = await t.get('/chat/send', { as: alice, json: { text: 'hi' }, origin: 'https://evil.test' });
- assert.strictEqual(r.status, 403);
- });
-
- await check('a chat belongs to the person who made it, and can be deleted', async () => {
- const del = await t.get(`${chatUrl}/delete`, { as: alice, method: 'POST' });
- assert.strictEqual(del.status, 303);
- assert.strictEqual((await t.get(chatUrl, { as: alice })).status, 404);
- });
- } finally { await t.close(); }
-
- const capped = await boot({ ai: createFakeAi(), env: { EXCHANGE_CHAT_PER_DAY: '1' } });
- const carol = capped.network.addUser('carol');
- try {
- await check('the daily allowance stops at its number', async () => {
- const first = await capped.get('/chat/send', { as: carol, form: { text: 'one' } });
- assert.strictEqual(first.status, 303);
- const second = await capped.get('/chat/send', { as: carol, form: { text: 'two' } });
- assert.strictEqual(second.status, 429);
- assert.ok(second.text.includes('resets at midnight UTC'));
- });
- } finally { await capped.close(); }
- done();
-})();
+ await check('every old chat path redirects to Vibe and carries selected models', async () => {
+ for (const path of ['/chat', '/chat/new', '/chat/exc_old', '/chat/exc_old/send']) {
+ const r = await t.get(path, { as: alice });
+ assert.equal(r.status, 301, path);
+ assert.equal(r.headers.get('location'), 'https://openvibe.chat/vibe');
+ }
+ const selected = await t.get('/chat?models=deepseek%2Fdeepseek-chat%2Cqwen%2Fqwen3-32b');
+ assert.equal(selected.status, 301);
+ assert.equal(selected.headers.get('location'), 'https://openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat%2Cqwen%2Fqwen3-32b');
+ const old = await t.get('/chat?models=deepseek%2Fdeepseek-chat%40v1');
+ assert.equal(old.headers.get('location'), 'https://openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat');
+ const page = await t.get('/m/deepseek/deepseek-chat');
+ assert.ok(page.text.includes('https://openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat'));
+ });
+ await check('export requires Chat capability and contains only the named person’s unsealed chats', async () => {
+ const mine = await t.ctx.chats.create({ owner: alice.subject, title: 'Alice history', models: ['deepseek/deepseek-chat'] });
+ await t.ctx.chats.append(alice.subject, mine.id, { role: 'user', content: 'Hello' });
+ await t.ctx.chats.append(alice.subject, mine.id, { role: 'assistant', model: 'deepseek/deepseek-chat', content: 'Hi' });
+ const theirs = await t.ctx.chats.create({ owner: bob.subject, title: 'Bob history' });
+ await t.ctx.chats.append(bob.subject, theirs.id, { role: 'user', content: 'Private' });
+ await t.ctx.chats.create({ owner: alice.subject, title: 'Sealed', sealed: true });
+ const path = '/internal/exchange/chat/export';
+ assert.equal((await t.get(path, { bearer: token([]), headers: { 'x-ov-subject': alice.subject } })).status, 403);
+ const out = await t.get(path, { bearer: token(['exchange.chat.export']), headers: { 'x-ov-subject': alice.subject } });
+ assert.equal(out.status, 200, out.text);
+ assert.equal(out.json().count, 1);
+ assert.deepEqual(out.json().chats[0].messages.map((m) => m.content), ['Hello', 'Hi']);
+ const other = await t.get(path, { bearer: token(['exchange.chat.export']), headers: { 'x-ov-subject': bob.subject } });
+ assert.equal(other.json().count, 1);
+ assert.equal(other.json().chats[0].title, 'Bob history');
+ });
+ } finally { await t.close(); done(); }
+})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -10,8 +10,8 @@ assert.ok(inBar.length <= 6, `at most six places in the bar, not ${inBar.length}
@@ -10,8 +10,8 @@ assert.ok(inBar.length <= 6, `at most six places in the bar, not ${inBar.length}
assert.ok(NAV.every((l) => /^fa-[a-z0-9-]+$/.test(l.icon || '')), 'every place has an icon');
assert.ok(NAV.every((l) => /^fa-[a-z0-9-]+$/.test(l.icon || '')), 'every place has an icon');
assert.ok(!NAV.some((l) => l.href === '/'), 'the brand is the way home');
assert.ok(!NAV.some((l) => l.href === '/'), 'the brand is the way home');
assert.deepStrictEqual(new Set(NAV.map((l) => l.href)).size, NAV.length, 'no place twice');
assert.deepStrictEqual(new Set(NAV.map((l) => l.href)).size, NAV.length, 'no place twice');
-for (const want of ['/models', '/chat', '/arena', '/start', '/api', '/why', '/own', '/sell', '/uncensored', '/ai', '/faq', '/status']) {
+for (const want of ['/models', 'https://openvibe.chat/vibe', '/arena', '/start', '/api', '/why', '/own', '/sell', '/uncensored', '/ai', '/faq', '/status']) {
assert.ok(NAV.some((l) => l.href === want), `${want} is reachable from the bar or More`);
assert.ok(NAV.some((l) => l.href === want), `${want} is reachable from the bar or More`);
}
}
-if (Array.isArray(PAGES)) for (const l of NAV) assert.ok(PAGES.some((p) => p.path === l.href) || ['/chat', '/status', '/arena', '/rankings'].includes(l.href), `${l.href} is a page`);
+if (Array.isArray(PAGES)) for (const l of NAV) assert.ok(PAGES.some((p) => p.path === l.href) || ['https://openvibe.chat/vibe', '/status', '/arena', '/rankings'].includes(l.href), `${l.href} is a page`);
console.log('nav: six places in the bar with icons, the rest under More, every page reachable');
console.log('nav: six places in the bar with icons, the rest under More, every page reachable');
@@ -58,7 +58,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -58,7 +58,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');
assert.ok(r.text.includes('no training') && r.text.includes('no retention') && r.text.includes('cn'), 'the data policy');
assert.ok(r.text.includes('no training') && r.text.includes('no retention') && r.text.includes('cn'), 'the data policy');
assert.ok(r.text.includes('99.0%'), 'uptime');
assert.ok(r.text.includes('99.0%'), 'uptime');
assert.ok(r.text.includes('400 ms'), 'p50');
assert.ok(r.text.includes('400 ms'), 'p50');
- assert.ok(r.text.includes('Sign in to try it'), 'a signed-out try asks for sign-in');
+ assert.ok(r.text.includes('https://openvibe.chat/vibe?models=deepseek%2Fdeepseek-chat'), 'Try it opens Chat with this model');
assert.ok(r.text.includes('Private'), 'the privacy label on an offer');
assert.ok(r.text.includes('Private'), 'the privacy label on an offer');
assert.ok(r.text.includes('Standard') || r.text.includes('No training'));
assert.ok(r.text.includes('Standard') || r.text.includes('No training'));
assert.ok(r.text.includes('Presets') && r.text.includes('openvibe/economy'), 'the presets box');
assert.ok(r.text.includes('Presets') && r.text.includes('openvibe/economy'), 'the presets box');
@@ -119,8 +119,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -119,8 +119,7 @@ const { createFakeAi, downAi } = require('./helpers/ai');
const paid = await t.get('/m/qwen/qwen3-32b');
const paid = await t.get('/m/qwen/qwen3-32b');
assert.ok(!paid.text.includes('Free to try'), 'no badge when the field is absent');
assert.ok(!paid.text.includes('Free to try'), 'no badge when the field is absent');
const chat = await t.get('/chat/new', { as: u });
const chat = await t.get('/chat/new', { as: u });
- assert.ok(chat.text.includes('Free to try'), 'the picker marks the free-tier models');
- assert.ok(chat.text.includes('need your own balance or key'), 'and says what the others need');
+ assert.strictEqual(chat.status, 301, 'the picker now lives in Chat');
});
});
await check('status: uptime per offer, the changelog and incidents', async () => {
await check('status: uptime per offer, the changelog and incidents', async () => {
@@ -1,34 +1,11 @@
@@ -1,34 +1,11 @@
'use strict';
'use strict';
-/**
- * Private AI on OpenVibe.Exchange (the plan of 2026-11-06, "Product lines and features", item 2):
- *
- * - the strict-privacy switch on a chat sends the private preset, and the answer's receipt carries the label;
- * - when the router refuses (no private offer can answer) the person sees its plain message, nothing else;
- * - a chat sealed with the person's Locker stores only the envelope: the database never sees a turn, the chat
- * list shows a neutral title, and the page never renders the conversation;
- * - the switch on the API page is the account's "default" preset in AI;
- * - one account never reads or writes another's sealed chat.
- */
-const assert = require('assert');
+/** Exchange's chat storage is retained for export, and its browser routes have moved to Chat. */
+const assert = require('node:assert/strict');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { createFakeAi } = require('./helpers/ai');
const { createFakeAi } = require('./helpers/ai');
-const ENVELOPE = Buffer.alloc(96, 7).toString('base64');
-const PLAINTEXT = 'a secret question about clocks';
-
-function makeAi({ chat = null } = {}) {
- const base = createFakeAi();
- const answer = async (args) => {
- if (chat) return chat(args);
- const out = await base.chat(args);
- return {
- ...out,
- privacy: args.preset === 'private' ? 'private' : 'anonymized',
- privacy_detail: { label: args.preset === 'private' ? 'private' : 'anonymized', retention: args.preset === 'private' ? 'none' : 'limited', training: args.preset === 'private' ? false : null, known: true },
- preset: args.preset || null,
- };
- };
- const ai = createFakeAi({ chat: answer });
+(async () => {
+ const ai = createFakeAi();
ai.presets = [];
ai.presets = [];
ai.presetCalls = [];
ai.presetCalls = [];
ai.presetsFor = async () => ai.presets;
ai.presetsFor = async () => ai.presets;
@@ -37,142 +14,24 @@ function makeAi({ chat = null } = {}) {
@@ -37,142 +14,24 @@ function makeAi({ chat = null } = {}) {
ai.presets = on ? [{ name: 'default', scope: 'owner', policy: { data_policy: 'private', community: false } }] : [];
ai.presets = on ? [{ name: 'default', scope: 'owner', policy: { data_policy: 'private', community: false } }] : [];
return { on };
return { on };
};
};
- return ai;
-}
-
-(async () => {
- const ai = makeAi();
const t = await boot({ ai });
const t = await boot({ ai });
const alice = t.network.addUser('alice');
const alice = t.network.addUser('alice');
- const bob = t.network.addUser('bob');
try {
try {
- await check('the chat page carries the strict-privacy switch, off by default', async () => {
- const r = await t.get('/chat/new', { as: alice });
- assert.strictEqual(r.status, 200, r.text);
- assert.ok(r.text.includes('name="strict_privacy"') && r.text.includes('Strict privacy'), 'the switch is there');
- assert.ok(!/name="strict_privacy" value="1" checked/.test(r.text), 'off by default');
- });
-
- let strictChat = null;
- await check('strict privacy sends the private preset and the receipt carries the label', async () => {
- const r = await t.get('/chat/send', {
- as: alice,
- form: { text: 'Who keeps my prompt?', strict_privacy: '1', 'models_deepseek/deepseek-chat': '1' },
- });
- assert.strictEqual(r.status, 303, r.text);
- strictChat = r.headers.get('location');
- assert.strictEqual(ai.calls.length, 1);
- assert.strictEqual(ai.calls[0].preset, 'private', 'the run names the private preset');
- assert.strictEqual(ai.calls[0].subject, alice.subject);
-
- const chat = await t.ctx.chats.get(alice.subject, strictChat.split('/').pop());
- assert.strictEqual(chat.strict_privacy, true, 'the switch is remembered on the chat');
-
- const page = await t.get(strictChat, { as: alice });
- assert.strictEqual(page.status, 200);
- assert.ok(/name="strict_privacy" value="1" checked/.test(page.text), 'the switch stays on');
- assert.ok(page.text.includes('<dt>Privacy</dt>'), 'the receipt has the privacy row');
- assert.ok(page.text.includes('Preset'), 'and the preset it used');
- });
-
- await check('turning the switch off sends no preset', async () => {
- const before = ai.calls.length;
- const r = await t.get(`${strictChat}/send`, { as: alice, form: { text: 'And now?', 'models_deepseek/deepseek-chat': '1' } });
- assert.strictEqual(r.status, 303);
- assert.strictEqual(ai.calls.length, before + 1);
- assert.strictEqual(ai.calls[ai.calls.length - 1].preset, null);
- const chat = await t.ctx.chats.get(alice.subject, strictChat.split('/').pop());
- assert.strictEqual(chat.strict_privacy, false);
+ await check('sealed chat browser routes redirect without writing an envelope', async () => {
+ const r = await t.get('/chat/sealed/new', { as: alice, json: { envelope: Buffer.alloc(96, 7).toString('base64') } });
+ assert.equal(r.status, 301);
+ assert.equal(r.headers.get('location'), 'https://openvibe.chat/vibe');
+ assert.equal((await t.ctx.chats.list(alice.subject)).length, 0);
});
});
-
- const refusing = makeAi({
- chat: async () => {
- const err = new Error('Strict privacy is on: no offer that keeps nothing and trains on nothing can answer right now. Nothing was sent to a provider that keeps or trains on your prompt, and the router does not fall back to one.');
- err.code = 'policy.privacy_unavailable';
- err.status = 503;
- throw err;
- },
- });
- const rt = await boot({ ai: refusing });
- const carol = rt.network.addUser('carol');
- try {
- await check('a strict refusal is shown as its plain message, never an answer', async () => {
- const r = await rt.get('/chat/send', { as: carol, form: { text: 'hi', strict_privacy: '1' } });
- assert.strictEqual(r.status, 303, r.text);
- const page = await rt.get(r.headers.get('location'), { as: carol });
- assert.strictEqual(page.status, 200);
- assert.ok(page.text.includes('Strict privacy is on'), 'the router’s own words');
- assert.ok(page.text.includes('does not fall back'), 'and why it refused');
- assert.ok(!page.text.includes('An answer from'), 'no model answered');
- });
- } finally { await rt.close(); }
-
- let sealedId = null;
- await check('a sealed chat is created from an envelope alone', async () => {
- const r = await t.get('/chat/sealed/new', { json: { envelope: ENVELOPE }, as: alice });
- assert.strictEqual(r.status, 201, r.text);
- sealedId = r.json().id;
- assert.match(sealedId, /^exc_/);
- assert.strictEqual(r.json().url, `/chat/${sealedId}`);
- });
-
- await check('the chat list shows the sealed title, and the page never renders the conversation', async () => {
- const list = await t.get('/chat', { as: alice });
- assert.strictEqual(list.status, 303, 'the first chat opens');
- const page = await t.get(`/chat/${sealedId}`, { as: alice });
- assert.strictEqual(page.status, 200);
- assert.ok(page.text.includes('id="ex-sealed"') && page.text.includes('data-sealed-form'), 'the shell');
- assert.ok(page.text.includes('ov-locker.js'), 'the Locker loads');
- assert.ok(!page.text.includes(PLAINTEXT));
- const dump = await t.dbDump();
- assert.ok(!dump.includes(PLAINTEXT), 'the database holds no plaintext of a sealed chat');
- assert.ok(dump.includes(ENVELOPE), 'it holds the envelope');
- const chat = await t.ctx.chats.get(alice.subject, sealedId);
- assert.strictEqual(chat.sealed, true);
- assert.strictEqual(chat.title, 'Sealed chat', 'a neutral title until the owner opens it');
- });
-
- await check('the envelope is readable and replaceable only by its owner', async () => {
- const got = await t.get(`/chat/${sealedId}/sealed`, { as: alice, headers: { accept: 'application/json' } });
- assert.strictEqual(got.status, 200, got.text);
- assert.strictEqual(got.json().envelope, ENVELOPE);
- const next = Buffer.alloc(120, 9).toString('base64');
- const put = await t.get(`/chat/${sealedId}/sealed`, { as: alice, method: 'PUT', json: { envelope: next } });
- assert.strictEqual(put.status, 200, put.text);
- assert.strictEqual((await t.get(`/chat/${sealedId}/sealed`, { as: alice })).json().envelope, next);
- // Another account: nothing, and it can neither read nor write it.
- assert.strictEqual((await t.get(`/chat/${sealedId}`, { as: bob })).status, 404);
- assert.strictEqual((await t.get(`/chat/${sealedId}/sealed`, { as: bob })).status, 404);
- assert.strictEqual((await t.get(`/chat/${sealedId}/sealed`, { as: bob, method: 'PUT', json: { envelope: ENVELOPE } })).status, 404);
- });
-
- await check('a sealed send runs the model and stores nothing', async () => {
- const before = ai.calls.length;
- const messages = [{ role: 'user', content: PLAINTEXT }];
- const r = await t.get(`/chat/${sealedId}/sealed/send`, { as: alice, json: { model: 'deepseek/deepseek-chat@v1', messages, strict_privacy: true } });
- assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(ai.calls.length, before + 1);
- assert.strictEqual(ai.calls[ai.calls.length - 1].preset, 'private');
- assert.deepStrictEqual(ai.calls[ai.calls.length - 1].messages, messages, 'the turn goes to the model');
- assert.ok(r.json().answer.content.includes('An answer from'));
- // Nothing of that turn is in the database: no message row, no plaintext anywhere.
- assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_chat_messages WHERE chat_id = $1', [sealedId])), 0);
- const dump = await t.dbDump();
- assert.ok(!dump.includes(PLAINTEXT));
- });
-
- await check('the strict switch on the API page is the account’s default preset in AI', async () => {
+ await check('the strict switch on the API page remains the account default preset in AI', async () => {
const page = await t.get('/api', { as: alice });
const page = await t.get('/api', { as: alice });
- assert.strictEqual(page.status, 200);
- assert.ok(page.text.includes('Strict privacy on my keys') && page.text.includes('action="/privacy/keys"'), 'the switch');
- const off = await t.get('/privacy/keys', { as: alice, form: { strict: '1' } });
- assert.strictEqual(off.status, 200);
- assert.deepStrictEqual(ai.presetCalls[ai.presetCalls.length - 1], { subject: alice.subject, on: true });
- const on = await t.get('/api', { as: alice });
- assert.ok(/name="strict" value="1" checked/.test(on.text), 'the switch reads back on');
+ assert.equal(page.status, 200);
+ assert.ok(page.text.includes('Strict privacy on my keys'));
+ const on = await t.get('/privacy/keys', { as: alice, form: { strict: '1' } });
+ assert.equal(on.status, 200);
+ assert.deepEqual(ai.presetCalls.at(-1), { subject: alice.subject, on: true });
await t.get('/privacy/keys', { as: alice, method: 'POST', form: {} });
await t.get('/privacy/keys', { as: alice, method: 'POST', form: {} });
- assert.deepStrictEqual(ai.presetCalls[ai.presetCalls.length - 1], { subject: alice.subject, on: false });
+ assert.deepEqual(ai.presetCalls.at(-1), { subject: alice.subject, on: false });
});
});
- } finally { await t.close(); }
- done();
-})();
+ } finally { await t.close(); done(); }
+})().catch((err) => { console.error(err); process.exitCode = 1; });
deleted file mode 100644
deleted file mode 100644
@@ -1,95 +0,0 @@
@@ -1,95 +0,0 @@
-'use strict';
-/**
- * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]): the page
- * carries markup only, ov-share.js draws the gate when it loads, signed out it offers Sign in, and a signed-in
- * person who is not on the list is offered Ask for access — with no console error and no CSP refusal.
- *
- * Skipped (exit 0) without Chrome, or with OV_SKIP_BROWSER=1. The page is served by this repo's own app on a
- * temporary database, so the CSP headers the browser sees are Exchange's.
- */
-const assert = require('assert');
-const fs = require('fs');
-const { boot, check, done } = require('./helpers/boot');
-const { createFakeAi } = require('./helpers/ai');
-
-let playwright = null;
-try { playwright = require('playwright'); } catch {
- try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }
-}
-const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';
-if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {
- console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');
- process.exit(0);
-}
-
-/** Console errors that mean the gate did not draw, rather than an incidental failed favicon. */
-const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;
-
-(async () => {
- const ai = createFakeAi();
- const t = await boot({ ai });
- const alice = t.network.addUser('alice');
- const carol = t.network.addUser('carol');
- const cookieOf = (user) => {
- const [name, ...rest] = t.signIn(user).split('=');
- return { name, value: rest.join('='), url: t.base };
- };
-
- let browser;
- const errors = [];
- const watch = (page) => {
- page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
- page.on('pageerror', (e) => errors.push(String(e)));
- };
- const settled = async (page) => {
- await page.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });
- return await page.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());
- };
-
- try {
- let chatId;
- await check('a chat shared with Specific people, with nobody on the list yet', async () => {
- const made = await t.get('/chat/send', { as: alice, form: { text: 'What is a semaphore?', 'models_deepseek/deepseek-chat': '1' } });
- assert.strictEqual(made.status, 303, made.text);
- chatId = made.headers.get('location').split('/').pop();
- await t.ctx.sharing.setAudience({ subject: alice.subject }, 'chat', chatId, 'people');
- const mine = await t.get(`/chat/${chatId}`, { as: alice });
- assert.strictEqual(mine.status, 200, mine.text);
- });
-
- await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {
- browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
- const ctx = await browser.newContext();
- const page = await ctx.newPage();
- watch(page);
- const res = await page.goto(`${t.base}/chat/${chatId}`, { waitUntil: 'load', timeout: 15000 });
- assert.strictEqual(res.status(), 404, 'the gate answers 404, never the chat');
- const text = await settled(page);
- assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);
- const href = await page.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));
- assert.match(href, /^\/auth\/login\?next=/);
- await ctx.close();
- });
-
- await check('headless Chrome: signed in as somebody not on the list, Ask for access', async () => {
- const ctx = await browser.newContext();
- await ctx.addCookies([cookieOf(carol)]);
- const page = await ctx.newPage();
- watch(page);
- await page.goto(`${t.base}/chat/${chatId}`, { waitUntil: 'load', timeout: 15000 });
- const text = await settled(page);
- assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);
- assert.ok(/@carol/.test(text), 'the gate names the account it is signed in as');
- await ctx.close();
- });
-
- await check('headless Chrome: no console error and no CSP refusal on the gate page', async () => {
- const refused = errors.filter((e) => REFUSED.test(e));
- assert.deepStrictEqual(refused, [], `the gate page was refused: ${refused.join(' | ')}`);
- });
- } finally {
- if (browser) await browser.close().catch(() => {});
- await t.close();
- done();
- }
-})();
@@ -42,68 +42,6 @@ const { createFakeBench } = require('./helpers/bench');
@@ -42,68 +42,6 @@ const { createFakeBench } = require('./helpers/bench');
'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['exchange', kind, id]);
'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['exchange', kind, id]);
try {
try {
- let chatId;
- await check('a new chat is Only me, and its page carries the one Share sheet', async () => {
- const made = await t.get('/chat/send', { as: alice, form: { text: 'What is a semaphore?', 'models_deepseek/deepseek-chat': '1' } });
- assert.strictEqual(made.status, 303, made.text);
- chatId = made.headers.get('location').split('/').pop();
- assert.deepStrictEqual(await audienceRow('chat', chatId), { audience: 'private' });
- const owner = await t.get(`/chat/${chatId}`, { as: alice });
- assert.strictEqual(owner.status, 200, owner.text);
- assert.ok(owner.text.includes('data-ov-share-sheet') && owner.text.includes('data-kind="chat"'), 'the Share sheet is mounted');
- assert.ok(owner.text.includes('ov-share.js'), 'the sheet loads with the page');
- const other = await t.get(`/chat/${chatId}`, { as: bob });
- assert.strictEqual(other.status, 404, 'another account is told nothing');
- assert.ok(!other.text.includes('What is a semaphore?'), 'never the title');
- const anon = await t.get(`/chat/${chatId}`);
- assert.strictEqual(anon.status, 404);
- });
-
- await check('Anyone with the link reads the conversation, and it has no composer', async () => {
- const saved = await setShare('chat', chatId, { audience: 'link' }, alice);
- assert.strictEqual(saved.status, 200, saved.text);
- assert.strictEqual(saved.json().audience, 'link');
- const anon = await t.get(`/chat/${chatId}`);
- assert.strictEqual(anon.status, 200, anon.text);
- assert.ok(anon.text.includes('What is a semaphore?'));
- assert.ok(anon.text.includes('An answer from deepseek/deepseek-chat@v1.'));
- assert.ok(!anon.text.includes('ovt-composer'), 'a reader cannot write into somebody else\'s chat');
- assert.ok(!anon.text.includes('name="text"'), 'no send box');
- });
-
- await check('Specific people: the person on the list reads it, another gets the gate and no title', async () => {
- const saved = await setShare('chat', chatId, { audience: 'people', members: [{ name: 'bob', role: 'viewer' }] }, alice);
- assert.strictEqual(saved.status, 200, saved.text);
- assert.strictEqual(saved.json().audience, 'people');
- const member = await t.get(`/chat/${chatId}`, { as: bob });
- assert.strictEqual(member.status, 200, member.text);
- assert.ok(member.text.includes('What is a semaphore?'));
- const stranger = await t.get(`/chat/${chatId}`, { as: carol });
- assert.strictEqual(stranger.status, 404);
- assert.ok(stranger.text.includes('shared with specific people'));
- assert.ok(!stranger.text.includes('What is a semaphore?'), 'the gate never reveals the title');
- const anon = await t.get(`/chat/${chatId}`);
- assert.strictEqual(anon.status, 404);
- assert.ok(anon.text.includes('shared with specific people'));
- });
-
- await check('a sealed chat has no Share sheet and cannot leave Only me', async () => {
- const envelope = Buffer.alloc(48, 7).toString('base64');
- const made = await t.get('/chat/sealed/new', { as: alice, json: { envelope } });
- assert.strictEqual(made.status, 201, made.text);
- const sealedId = made.json().id;
- const page = await t.get(`/chat/${sealedId}`, { as: alice });
- assert.strictEqual(page.status, 200);
- assert.ok(page.text.includes('Only me, always'));
- assert.ok(!page.text.includes('data-ov-share-sheet'), 'no Share sheet on a sealed chat');
- const refused = await setShare('chat', sealedId, { audience: 'public' }, alice);
- assert.strictEqual(refused.status, 400, refused.text);
- assert.strictEqual(refused.json().code, 'share.invalid');
- assert.deepStrictEqual(await audienceRow('chat', sealedId), { audience: 'private' });
- const other = await t.get(`/chat/${sealedId}`, { as: bob });
- assert.strictEqual(other.status, 404);
- });
-
let roundId;
let roundId;
await check('a new Arena round is Only me, and a shared round names both models with no vote', async () => {
await check('a new Arena round is Only me, and a shared round names both models with no vote', async () => {
const asked = await t.get('/arena/ask', { as: alice, form: { prompt: 'Write a function that reads a CSV and sums a column' } });
const asked = await t.get('/arena/ask', { as: alice, form: { prompt: 'Write a function that reads a CSV and sums a column' } });
@@ -121,20 +59,6 @@ const { createFakeBench } = require('./helpers/bench');
@@ -121,20 +59,6 @@ const { createFakeBench } = require('./helpers/bench');
assert.ok(!seen.text.includes('name="outcome"'), 'a reader does not vote on somebody else\'s round');
assert.ok(!seen.text.includes('name="outcome"'), 'a reader does not vote on somebody else\'s round');
});
});
- await check('the person\'s default applies to the next chat, and "apply to all" moves the existing ones', async () => {
- const saved = await t.get('/chat/defaults', { as: alice, form: { audience: 'public', apply_all: '1' } });
- assert.strictEqual(saved.status, 303, saved.text);
- assert.deepStrictEqual(await audienceRow('chat', chatId), { audience: 'public' });
- const next = await t.get('/chat/send', { as: alice, form: { text: 'A second question', 'models_deepseek/deepseek-chat': '1' } });
- const nextId = next.headers.get('location').split('/').pop();
- assert.deepStrictEqual(await audienceRow('chat', nextId), { audience: 'public' });
- const anon = await t.get(`/chat/${nextId}`);
- assert.strictEqual(anon.status, 200);
- assert.ok(anon.text.includes('A second question'));
- const start = await t.get('/chat/new', { as: alice });
- assert.ok(start.text.includes('New chats'), 'the default is on the product page');
- });
-
await check('characters: Only me while being written, Specific people to show one, the default for new cards', async () => {
await check('characters: Only me while being written, Specific people to show one, the default for new cards', async () => {
const FORM = { name: 'Ines', description: 'A cartographer', personality: 'patient', scenario: 'a map room', first_message: 'Where to?', rating: 'everyone' };
const FORM = { name: 'Ines', description: 'A cartographer', personality: 'patient', scenario: 'a map room', first_message: 'Where to?', rating: 'everyone' };
const made = await t.get('/companions/new', { as: alice, form: FORM });
const made = await t.get('/companions/new', { as: alice, form: FORM });