openvibers/openvibe.exchange Commits

Exchange: private AI — the strict privacy switch and sealed chats (#92)

OP OpenVibers committed 7 days ago · 0ef3fafde3 · 12 files, +939 −22

* Exchange: private AI — the strict privacy switch and sealed chats

Strict privacy is a switch on /chat and on the API page's keys. It sends AI
the private preset, so only offers that keep nothing and train on nothing (or
OpenVibe's own deployments) may answer; the router refuses with a plain
message rather than fall back, and every receipt carries the answer's label
(Private, No training, Anonymized, Community, Standard).

A chat can also be sealed with the person's Locker: the browser seals the
whole conversation (ov-locker.js, people-locked to the person) and Exchange
stores only the envelope (exchange_chat_seals). The chat list shows a neutral
title, the page never renders a turn, and the model still receives the turn it
answers — said plainly on the page. Account export carries the envelope and
deletion erases it.

openvibe-shared 4.115.1 → 4.117.0 for OVLocker.seal/publicKeys/ensure.

* Merge remote-tracking branch 'origin/main' into exchange/private-ai

# Conflicts:
#	package-lock.json
#	package.json
#	server/views/exchange.js

* Checkboxes sit beside their words: ex-check labels carry the kit's ovk-check (.ovk-form label is a grid, which stacked them)

* Merge remote-tracking branch 'origin/main' into review-92

# Conflicts:
#	public/css/exchange.css
#	test/account-data.test.js

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 7320539..70a3b58 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,14 @@ What changed in OpenVibe.Exchange, newest first.
## Unreleased
++- **Private AI.** Strict privacy is a switch on `/chat` and on the API page's keys. It sends AI the `private` preset:
++ only offers that keep nothing and train on nothing (or OpenVibe's own deployments) may answer, the router refuses
++ with a plain message when none can, and every answer's receipt carries its label (Private, No training, Anonymized,
++ Community, Standard).
++- **Sealed history.** A chat can be sealed with the person's Locker: the browser seals the whole conversation
++ (`ov-locker.js`, people-locked to the person) and Exchange stores only the envelope. The chat list shows a neutral
++ title until it is opened, the model still receives the turn it answers, and the page says so plainly. Account
++ export carries the envelope and deletion erases it.
- **Theme palettes.** A Design theme's listing keeps the preview palette from Design's public API (background,
surface, text, accent and up to two more), in a new `palette` array on the API; the card and the listing page draw
it as small swatches. Only `#rrggbb` values are stored and painted; a second refresh leaves the row untouched.
ddiff --git a/migrations/0009_private_chat.sql b/migrations/0009_private_chat.sql
nnew file mode 100644
iindex 0000000..2cba6e3
---- /dev/null
++++ b/migrations/0009_private_chat.sql
@@@ -0,0 +1,25 @@
++-- phase: expand
++-- Private AI (the plan of 2026-11-06, "Product lines and features", item 2): a strict privacy switch on a chat,
++-- and a chat whose history is sealed in the person's Locker.
++--
++-- strict_privacy: the chat's requests name the private preset, so only an offer that keeps nothing and trains on
++-- nothing may answer. The router refuses rather than falling back to another offer.
++--
++-- sealed: the conversation never reaches this database in plain text. The browser seals it with the person's
++-- Locker (openvibe-sdk/locker, people-locked to the person) and Exchange keeps the envelope in
++-- exchange_chat_seals. The title in exchange_chats is the neutral "Sealed chat": the real title is inside the
++-- envelope, and only the person's browser can read it.
++
++ALTER TABLE exchange_chats ADD COLUMN IF NOT EXISTS strict_privacy boolean NOT NULL DEFAULT false;
++ALTER TABLE exchange_chats ADD COLUMN IF NOT EXISTS sealed boolean NOT NULL DEFAULT false;
++
++CREATE TABLE IF NOT EXISTS exchange_chat_seals (
++ chat_id text COLLATE "C" PRIMARY KEY REFERENCES exchange_chats (id) ON DELETE CASCADE,
++ owner text COLLATE "C" NOT NULL, -- denormalised for export and deletion
++ envelope text NOT NULL, -- base64 of a locker.envelope.v1 (people lock)
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT exchange_chat_seals_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ -- 512 KiB of ciphertext: far more than a conversation needs, and never a place to store something else.
++ CONSTRAINT exchange_chat_seals_size CHECK (length(envelope) <= 700000)
++);
++CREATE INDEX IF NOT EXISTS exchange_chat_seals_owner ON exchange_chat_seals (owner, updated_at DESC);
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex ea68839..51f26b4 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -460,6 +460,62 @@ body.ex-companion-shell > main.ex { flex: 1; min-height: 0; display: flex; }
.ex-branch-arrow { min-width: 44px; min-height: 44px; }
}
++/* ── Strict privacy and a sealed chat ─────────────────────── */
++.ex-check {
++ display: flex; align-items: flex-start; gap: 9px;
++ margin: 10px 0 4px; font-size: 14.5px; line-height: 1.5; color: var(--ovk-text);
++ cursor: pointer;
++}
++.ex-check input { flex: none; width: 18px; height: 18px; margin: 2px 0 0; accent-color: var(--ovk-accent, currentColor); }
++.ex-small { font-size: 12.5px; margin: 2px 0 0; }
++.ex-switch { max-width: 640px; }
++.ex-switch .ovk-btn { margin-top: 8px; }
++
++.ex-sealed-card {
++ display: grid; gap: 14px; margin-top: 18px;
++ padding: 16px; border-radius: 16px;
++ border: 1px solid var(--ovk-line); background: var(--ovk-panel);
++}
++.ex-sealed-title { margin: 0; font-size: 20px; font-weight: 650; letter-spacing: -.01em; }
++.ex-sealed-status { margin: 0; color: var(--ovk-muted); font-size: 14px; line-height: 1.55; }
++.ex-sealed-status.is-ready { color: var(--ovk-text); }
++.ex-sealed-turns { display: grid; gap: 12px; max-height: 58vh; overflow: auto; }
++.ex-sealed-turns:empty { display: none; }
++.ex-turn {
++ display: grid; gap: 4px; max-width: 760px;
++ padding: 12px 14px; border-radius: 14px;
++ border: 1px solid var(--ovk-line); background: var(--ovk-panel-2, var(--ovk-panel));
++}
++.ex-turn--user { background: transparent; justify-self: end; }
++.ex-turn-who { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--ovk-muted); }
++.ex-turn-text { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; font-size: 15px; }
++.ex-sealed-form { display: grid; gap: 8px; }
++.ex-sealed-form textarea {
++ width: 100%; box-sizing: border-box; min-height: 92px; padding: 10px 12px;
++ border-radius: 12px; border: 1px solid var(--ovk-line);
++ background: var(--ovk-input, var(--ovk-panel)); color: var(--ovk-text); font: inherit; line-height: 1.55; resize: vertical;
++}
++.ex-sealed-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 18px; }
++.ex-sealed-row select {
++ min-height: 40px; padding: 6px 10px; border-radius: 10px;
++ border: 1px solid var(--ovk-line); background: var(--ovk-input, var(--ovk-panel)); color: var(--ovk-text); font: inherit;
++}
++.ex-sealed-note {
++ margin: 16px 0 0; max-width: 68ch; font-size: 13px; line-height: 1.6; color: var(--ovk-muted);
++}
++.ex-sealed-note strong { color: var(--ovk-text); }
++
++@media (max-width: 640px) {
++ .ex-sealed-card { padding: 12px; border-radius: 14px; }
++ .ex-sealed-turns { max-height: none; }
++ .ex-sealed-row select { width: 100%; }
++ .ex-sealed-row .ex-check { min-height: 44px; }
++}
++
++@media (max-width: 700px) {
++ .ex-check { min-height: 44px; align-items: center; }
++}
++
/* ── Companions: memory and lorebooks ─────────────────────────────────── */
.ex-chat-links { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 12px; }
.ex-locked { margin: 0 0 8px; font-size: .88rem; color: var(--ovk-muted); }
ddiff --git a/public/js/exchange.js b/public/js/exchange.js
iindex 5ea4d1f..3a4aad4 100644
---- a/public/js/exchange.js
++++ b/public/js/exchange.js
@@@ -1 +1,217 @@
--/* OpenVibe.Exchange: the room's own script is designed by the main session; the pages are complete without this file. */
++/* OpenVibe.Exchange: a sealed chat. The conversation is sealed with the person's Locker (people-locked to them) in
++ * this tab; the page only ever receives and sends the envelope. OpenVibe cannot read a sealed chat. */
++(function () {
++ 'use strict';
++ var root = document.getElementById('ex-sealed');
++ if (!root) return;
++
++ var chatId = root.getAttribute('data-chat') || '';
++ var subject = root.getAttribute('data-subject') || '';
++ var network = root.getAttribute('data-network') || 'https://openvibe.network';
++ window.OpenVibeLockerConfig = window.OpenVibeLockerConfig || { network: network };
++
++ var statusEl = root.querySelector('[data-sealed-status]');
++ var turnsEl = root.querySelector('[data-sealed-turns]');
++ var form = root.querySelector('[data-sealed-form]');
++ var textarea = form && form.querySelector('textarea[name="text"]');
++ var modelSel = form && form.querySelector('select[name="model"]');
++ var strictBox = form && form.querySelector('input[name="strict_privacy"]');
++ var msg = form && form.querySelector('[data-sealed-msg]');
++
++ var state = { title: '', models: [], messages: [] };
++ var publicKey = null;
++
++ function say(text, bad) {
++ if (!msg) return;
++ msg.textContent = text || '';
++ msg.className = bad ? 'ovk-msg is-error' : 'ovk-msg';
++ }
++ function encode(text) { return new TextEncoder().encode(text); }
++ function decode(bytes) { return new TextDecoder().decode(bytes); }
++ function b64(bytes) {
++ var s = '';
++ for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
++ return btoa(s);
++ }
++ function unb64(text) {
++ var raw = atob(String(text || ''));
++ var out = new Uint8Array(raw.length);
++ for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
++ return out;
++ }
++
++ /** The person's own public key, after the Locker is unlocked here (the unlock sheet when it is not). */
++ async function ready() {
++ if (!window.OVLocker) throw new Error('The Locker could not load. Reload the page.');
++ var st = await window.OVLocker.ensure();
++ if (!st || !st.unlocked) throw new Error('Unlock your Locker to use a sealed chat.');
++ if (!publicKey) {
++ var keys = await window.OVLocker.publicKeys();
++ publicKey = keys && keys.ecdh;
++ }
++ if (!publicKey) throw new Error('Your Locker has no public key yet.');
++ }
++
++ /** The whole conversation, sealed for this person in this tab: only bytes leave the page. */
++ async function sealNow() {
++ var out = await window.OVLocker.seal(encode(JSON.stringify(state)), { lock: 'people', keys: [{ subject: subject, publicKey: publicKey }] });
++ return b64(out.bytes);
++ }
++
++ function node(tag, cls, text) {
++ var n = document.createElement(tag);
++ if (cls) n.className = cls;
++ if (text != null) n.textContent = text;
++ return n;
++ }
++ function pair(dl, k, v) { dl.appendChild(node('dt', null, k)); dl.appendChild(node('dd', null, v)); }
++ function privacyText(label) {
++ if (label === 'private') return 'Private · keeps nothing, trains on nothing';
++ if (label === 'community') return 'Community · a person’s machine sees the prompt';
++ if (label === 'anonymized') return 'Anonymized';
++ return '—';
++ }
++
++ function render() {
++ if (!turnsEl) return;
++ turnsEl.replaceChildren();
++ var title = root.querySelector('[data-sealed-title]');
++ if (title) {
++ title.textContent = state.title || 'A sealed chat';
++ title.hidden = !state.title;
++ }
++ if (state.title) document.title = state.title + ' · OpenVibe.Exchange';
++ for (var i = 0; i < state.messages.length; i++) {
++ var m = state.messages[i];
++ if (m.role === 'system') continue;
++ var turn = node('article', 'ex-turn ex-turn--' + m.role);
++ turn.appendChild(node('p', 'ex-turn-who', m.role === 'user' ? 'You' : (m.model || 'The model')));
++ turn.appendChild(node('div', 'ex-turn-text', m.content || ''));
++ var meta = m.meta;
++ if (meta && (meta.privacy || meta.provider || meta.cost_usd != null)) {
++ var d = node('details', 'ex-receipt');
++ d.appendChild(node('summary', null, 'Receipt'));
++ var dl = node('dl', null);
++ pair(dl, 'Offer', meta.provider ? String(meta.provider) : '—');
++ pair(dl, 'Privacy', privacyText(meta.privacy));
++ if (meta.preset) pair(dl, 'Preset', String(meta.preset));
++ pair(dl, 'Cost', meta.cost_usd == null ? '—' : '$' + Number(meta.cost_usd).toFixed(6));
++ if (meta.latency_ms != null) pair(dl, 'Latency', Number(meta.latency_ms).toLocaleString('en-US') + ' ms');
++ d.appendChild(dl);
++ turn.appendChild(d);
++ }
++ turnsEl.appendChild(turn);
++ }
++ turnsEl.scrollTop = turnsEl.scrollHeight;
++ }
++
++ async function openExisting() {
++ var res = await fetch('/chat/' + encodeURIComponent(chatId) + '/sealed', { headers: { accept: 'application/json' } });
++ if (!res.ok) throw new Error('This sealed chat could not be read.');
++ var body = await res.json();
++ if (!body.envelope) throw new Error('This sealed chat has no envelope yet.');
++ if (strictBox) strictBox.checked = body.strict_privacy !== false;
++ await window.OVLocker.open(statusEl, {
++ bytes: unb64(body.envelope),
++ title: 'A sealed chat',
++ onOpen: function (plain) {
++ var parsed = JSON.parse(decode(plain));
++ state = {
++ title: parsed.title || '',
++ models: Array.isArray(parsed.models) ? parsed.models : [],
++ messages: Array.isArray(parsed.messages) ? parsed.messages : [],
++ };
++ statusEl.hidden = true;
++ render();
++ },
++ });
++ }
++
++ async function save() {
++ var envelope = await sealNow();
++ var res = await fetch('/chat/' + encodeURIComponent(chatId) + '/sealed', {
++ method: 'PUT', headers: { 'content-type': 'application/json', accept: 'application/json' },
++ body: JSON.stringify({ envelope: envelope }),
++ });
++ if (!res.ok) throw new Error('The sealed chat was not saved.');
++ }
++
++ async function send(event) {
++ event.preventDefault();
++ var text = String((textarea && textarea.value) || '').trim();
++ if (!text) return say('Write something to send.', true);
++ var model = modelSel && modelSel.value;
++ if (!model) return say('Pick a model first.', true);
++ var strict = Boolean(strictBox && strictBox.checked);
++ say('Sealing the turn on this device…');
++ try {
++ await ready();
++ } catch (err) { return say(err.message, true); }
++ if (!state.title) state.title = text.replace(/\s+/g, ' ').slice(0, 60);
++ state.messages.push({ role: 'user', content: text });
++ if (state.models.indexOf(model) === -1) state.models.push(model);
++ render();
++ if (textarea) textarea.value = '';
++ try {
++ // The chat is made here, with the first envelope: the server never sees a turn before it is sealed.
++ if (!chatId) {
++ var made = await fetch('/chat/sealed/new', {
++ method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
++ body: JSON.stringify({ envelope: await sealNow() }),
++ });
++ var created = await made.json().catch(function () { return {}; });
++ if (!made.ok || !created.id) throw new Error(created.error || 'The sealed chat was not created.');
++ chatId = created.id;
++ root.setAttribute('data-chat', chatId);
++ if (window.history && window.history.replaceState) window.history.replaceState(null, '', created.url || ('/chat/' + chatId));
++ }
++ say('Sending the turn…');
++ var res = await fetch('/chat/' + encodeURIComponent(chatId) + '/sealed/send', {
++ method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
++ body: JSON.stringify({
++ model: model,
++ messages: state.messages.map(function (m) { return { role: m.role, content: m.content }; }),
++ strict_privacy: strict,
++ }),
++ });
++ var body = await res.json().catch(function () { return {}; });
++ if (!res.ok || !body.answer) throw new Error(body.error || 'The model did not answer.');
++ var a = body.answer;
++ state.messages.push({
++ role: 'assistant', content: a.content, model: model,
++ meta: { provider: a.provider, privacy: a.privacy, preset: a.preset, cost_usd: a.cost_usd, latency_ms: a.latency_ms },
++ });
++ await save();
++ render();
++ say('Sealed and saved on this device.');
++ } catch (err) {
++ state.messages.push({ role: 'assistant', content: '[No answer] ' + (err.message || 'The model did not answer.'), model: model });
++ try { if (chatId) await save(); } catch (e) { /* the page says so below */ }
++ render();
++ say(err.message || 'The model did not answer.', true);
++ }
++ }
++
++ async function start() {
++ if (statusEl) statusEl.textContent = 'Opening your Locker…';
++ try {
++ await ready();
++ if (chatId) await openExisting();
++ else if (statusEl) {
++ statusEl.textContent = 'Your Locker is open. The first message makes the chat, sealed before it leaves this page.';
++ statusEl.classList.add('is-ready');
++ }
++ if (form) form.hidden = false;
++ if (textarea) textarea.focus();
++ } catch (err) {
++ if (statusEl) statusEl.textContent = err.message || 'The Locker could not start.';
++ var retry = node('button', 'ovk-btn ovk-btn--quiet', 'Try again');
++ retry.type = 'button';
++ retry.addEventListener('click', function () { retry.remove(); start(); });
++ if (statusEl && statusEl.parentNode) statusEl.parentNode.insertBefore(retry, statusEl.nextSibling);
++ }
++ }
++
++ if (form) form.addEventListener('submit', send);
++ start();
++})();
ddiff --git a/scripts/shoot-private.js b/scripts/shoot-private.js
nnew file mode 100644
iindex 0000000..0684f6a
---- /dev/null
++++ b/scripts/shoot-private.js
@@@ -0,0 +1,122 @@
++'use strict';
++/**
++ * The design-review screenshots for private AI (Opus, 2026-11-06): a chat with a private answer and its receipt,
++ * the API page's strict-privacy switch, and a sealed chat's shell, at 390 and 1280, in dark and light.
++ *
++ * node scripts/shoot-private.js [outDir]
++ *
++ * The shots go to a folder outside the repository (a temporary one by default): they are pictures of the site, not
++ * code, and they are never committed.
++ *
++ * It boots the real app on a temp database with a mock Network and a stand-in AI, and drives headless Chrome (the
++ * ov-phone-shots recipe). It checks the width (scrollWidth === viewport), the console, and the tap targets at
++ * 390 px, and says what it found. The sealed chat is shown locked: the Locker's key directory is Network's, and
++ * this page's own script has nothing to unlock without a real person. What is reviewed here is the shell.
++ */
++const path = require('path');
++const os = require('os');
++const { boot } = require('../test/helpers/boot');
++const { createFakeAi } = require('../test/helpers/ai');
++const { chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright');
++
++const OUT = path.resolve(process.argv[2] || path.join(os.tmpdir(), 'exchange-private-shots'));
++const CHROME = '/usr/bin/google-chrome';
++
++function privateAi() {
++ const base = createFakeAi();
++ return {
++ ...base,
++ presets: [],
++ async presetsFor() { return this.presets; },
++ async setKeyPrivacy(subject, on) { this.presets = on ? [{ name: 'default', scope: 'owner', policy: { data_policy: 'private' } }] : []; return { on }; },
++ async 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' : 'standard', training: args.preset === 'private' ? false : null, region: null, trust: 'first-party', known: true },
++ preset: args.preset || null,
++ };
++ },
++ };
++}
++
++(async () => {
++ const ai = privateAi();
++ const t = await boot({ ai });
++ const alice = t.network.addUser('alice');
++
++ // A chat with a strict answer, so the receipt has its privacy row.
++ const sent = await t.get('/chat/send', {
++ as: alice,
++ form: { text: 'Which offers keep nothing? I would like a straight answer about data retention.', strict_privacy: '1', 'models_deepseek/deepseek-chat': '1' },
++ });
++ if (sent.status !== 303) throw new Error('the chat was not made: ' + sent.status + ' ' + sent.text.slice(0, 300));
++ const chatUrl = sent.headers.get('location');
++ await t.get(chatUrl + '/send', { as: alice, form: { text: 'And in one sentence?', strict_privacy: '1', 'models_deepseek/deepseek-chat': '1' } });
++
++ // The key switch on, so the API page shows it on.
++ await t.get('/privacy/keys', { as: alice, form: { strict: '1' } });
++
++ // A sealed chat's shell (locked: no Locker in a headless browser without a person).
++ const sealed = await t.get('/chat/sealed/new', { as: alice, json: { envelope: Buffer.alloc(96, 7).toString('base64') } });
++ const sealedUrl = sealed.json().url;
++
++ const cookie = t.signIn(alice);
++ const base = t.base;
++ const shots = [
++ { name: 'chat-private', path: chatUrl },
++ { name: 'api-strict', path: '/api' },
++ { name: 'sealed-chat', path: sealedUrl },
++ ];
++
++ const browser = await chromium.launch({ executablePath: CHROME });
++ const problems = [];
++ for (const shot of shots) {
++ for (const [w, scheme] of [[390, 'dark'], [390, 'light'], [1280, 'dark'], [1280, 'light']]) {
++ const ctx = await browser.newContext({
++ viewport: { width: w, height: 900 }, colorScheme: scheme, deviceScaleFactor: 2,
++ isMobile: w < 700, hasTouch: w < 700,
++ });
++ const url = new URL(base);
++ await ctx.addCookies([
++ { name: 'exchange_at', value: cookie.split('=')[1], domain: url.hostname, path: '/' },
++ { name: 'ov_token', value: cookie.split('=')[1], domain: url.hostname, path: '/' },
++ { name: 'ov_theme_id', value: scheme === 'light' ? 'daylight' : 'vibe', domain: url.hostname, path: '/' },
++ ]);
++ const pg = await ctx.newPage();
++ const errs = [];
++ const ours = (text) => !/openvibe\.network|CORS|Failed to load resource|net::ERR/.test(String(text));
++ pg.on('pageerror', (e) => { if (ours(e.message)) errs.push(e.message); });
++ pg.on('console', (m) => { if (m.type() === 'error' && ours(m.text())) errs.push(m.text()); });
++ await pg.goto(base + shot.path, { waitUntil: 'load' });
++ await pg.waitForTimeout(900);
++ const docW = await pg.evaluate(() => document.documentElement.scrollWidth);
++ if (docW !== w) problems.push(shot.name + ' ' + w + ' ' + scheme + ': scrollWidth ' + docW + ' > ' + w);
++ if (errs.length) problems.push(shot.name + ' ' + w + ' ' + scheme + ': console: ' + errs.join(' | '));
++ if (w === 390) {
++ const small = await pg.evaluate(() => {
++ const out = [];
++ for (const el of document.querySelectorAll('a, button, input, select, textarea')) {
++ if (el.closest('nav, .ovnav, .openvibe-navbar, .skip, .ovf-legal-inline, .ovf-col--brand, [data-network-navbar]')) continue;
++ const r = el.getBoundingClientRect();
++ if (!r.width || !r.height) continue;
++ const st = getComputedStyle(el);
++ if (st.opacity === '0' || st.visibility === 'hidden' || el.closest('.ovk-sr, .sr-only, .visually-hidden')) continue;
++ if (r.height < 44) out.push(el.tagName + '.' + (el.className || '') + ' [' + String(el.textContent || '').trim().slice(0, 20) + '] h=' + Math.round(r.height));
++ }
++ return out.slice(0, 8);
++ });
++ if (small.length) problems.push(shot.name + ' 390 ' + scheme + ': small tap targets: ' + small.join(', '));
++ }
++ await pg.screenshot({ path: path.join(OUT, shot.name + '-' + w + '-' + scheme + '.png'), fullPage: true });
++ await pg.close();
++ await ctx.close();
++ }
++ }
++ await browser.close();
++ process.stdout.write((problems.length ? 'PROBLEMS:\n' + problems.join('\n') : 'clean: no overflow, no console errors, tap targets ok') + '\n');
++ process.stdout.write('shots: ' + OUT + '\n');
++ await t.close();
++ process.exit(problems.length ? 1 : 0);
++})();
ddiff --git a/server/ai.js b/server/ai.js
iindex 5697643..64d7275 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -134,6 +134,9 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
? (Array.isArray(explain.reasons) && explain.reasons.length ? explain.reasons.slice(0, 4).map(String)
: (explain.selected || explain.policy || explain.objective || null))
: null;
++ // The privacy label the router put on the run's own explain (server/exchange/privacy.js in AI): Private,
++ // Anonymized or Community, with the facts behind it. The answer shows it beside the receipt.
++ const privacy = explain && typeof explain === 'object' && explain.privacy && typeof explain.privacy === 'object' ? explain.privacy : null;
return {
content,
model: (run.output && run.output.model) || (run.provenance && run.provenance.model) || null,
@@@ -145,10 +148,77 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
tokens_out: usage ? usage.tokens_out || 0 : 0,
latency_ms: Number.isFinite(started) && Number.isFinite(finished) && finished >= started ? finished - started : null,
why: why == null ? null : (Array.isArray(why) ? why : [String(why)]),
++ privacy: privacy ? privacy.label || null : null,
++ privacy_detail: privacy,
++ preset: explain && explain.preset && explain.preset.name ? String(explain.preset.name) : null,
};
}
-- return { catalog, usage, chat, configured: Boolean(tokens) };
++ /** The presets saved for a person on AI (GET /api/v1/presets), or null when the read fails. */
++ async function presetsFor(subject) {
++ if (!tokens || !subject) return null;
++ try {
++ const token = await tokens.getToken();
++ const res = await fetchImpl(`${config.ai.url}/api/v1/presets?on_behalf_of=${encodeURIComponent(subject)}`, {
++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(config.ai.timeoutMs),
++ });
++ const body = await jsonOf(res);
++ return res.ok && body && Array.isArray(body.presets) ? body.presets : null;
++ } catch (err) {
++ warned('presets', err);
++ return null;
++ }
++ }
++
++ /** The developer projects the person owns (Network's GET /api/v1/projects), as ids; [] when it cannot be read. */
++ async function ownedProjects(token, subject) {
++ if (!token || !subject) return [];
++ try {
++ const res = await fetchImpl(`${config.networkUrl}/api/v1/projects`, {
++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(config.ai.timeoutMs),
++ });
++ const body = await jsonOf(res);
++ const projects = res.ok && body && Array.isArray(body.projects) ? body.projects : [];
++ // Only the person's own projects: a shared one may carry other members' traffic.
++ return projects.filter((p) => p && p.id && p.owner && p.owner.id === subject).map((p) => String(p.id));
++ } catch (err) {
++ warned('projects', err);
++ return [];
++ }
++ }
++
++ /**
++ * The strict-privacy switch on a person's keys: their "default" preset in AI makes every request that names no
++ * preset private, and turning it off deletes it. The keys are their projects' keys too, so the same preset is
++ * written on each project they own; a project that cannot be written is counted, never hidden.
++ */
++ async function setKeyPrivacy(subject, on, { networkToken = null } = {}) {
++ if (!tokens || !subject) throw Object.assign(new Error('AI is not configured for this service'), { code: 'ai.not_configured', status: 503 });
++ const token = await tokens.getToken();
++ const targets = [{ type: 'user', id: String(subject) }];
++ for (const id of await ownedProjects(networkToken, subject)) targets.push({ type: 'project', id });
++ let written = 0;
++ const failed = [];
++ for (const target of targets) {
++ const res = await fetchImpl(`${config.ai.url}/api/v1/presets${on ? '' : '/default'}`, {
++ method: on ? 'POST' : 'DELETE',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` },
++ body: JSON.stringify({ on_behalf_of: target, ...(on ? { name: 'default', policy: { data_policy: 'private', community: false } } : {}) }),
++ signal: AbortSignal.timeout(config.ai.timeoutMs),
++ });
++ const body = await jsonOf(res);
++ if (res.ok) { written += 1; continue; }
++ if (!on && res.status === 404) { written += 1; continue; } // nothing to remove: the switch is off
++ const err = new Error((body && (body.detail || body.title)) || `AI answered ${res.status}`);
++ err.code = (body && body.code) || 'ai.failed';
++ err.status = res.status;
++ if (target.type === 'user') throw err; // the person's own preset is the switch: a failure is not hidden
++ failed.push(target.id);
++ }
++ return { on: Boolean(on), projects: targets.length - 1, projects_set: Math.max(0, written - 1), projects_failed: failed };
++ }
++
++ return { catalog, usage, chat, presetsFor, setKeyPrivacy, ownedProjects, configured: Boolean(tokens) };
}
/**
ddiff --git a/server/chat/store.js b/server/chat/store.js
iindex e0b34b0..d88e3b6 100644
---- a/server/chat/store.js
++++ b/server/chat/store.js
@@@ -9,10 +9,10 @@
function createChatStore(s) {
const db = s.db;
-- async function create({ owner, models = [], title = 'New chat' }) {
++ async function create({ owner, models = [], title = 'New chat', sealed = false, strictPrivacy = false }) {
const id = s.newId('exc');
-- await db.prepare(`INSERT INTO exchange_chats (id, owner, title, models) VALUES (?, ?, ?, ?)`)
-- .run(id, owner, String(title).slice(0, 120) || 'New chat', JSON.stringify(models.slice(0, 3)));
++ await db.prepare(`INSERT INTO exchange_chats (id, owner, title, models, sealed, strict_privacy) VALUES (?, ?, ?, ?, ?, ?)`)
++ .run(id, owner, String(title).slice(0, 120) || 'New chat', JSON.stringify(models.slice(0, 3)), Boolean(sealed), Boolean(strictPrivacy));
return get(owner, id);
}
@@@ -22,7 +22,7 @@ function createChatStore(s) {
}
async function list(owner, limit = 60) {
-- const rows = await db.prepare('SELECT id, title, models, updated_at FROM exchange_chats WHERE owner = ? ORDER BY updated_at DESC LIMIT ?')
++ const rows = await db.prepare('SELECT id, title, models, sealed, strict_privacy, updated_at FROM exchange_chats WHERE owner = ? ORDER BY updated_at DESC LIMIT ?')
.all(owner, Math.max(1, Math.min(200, limit)));
return rows.map((r) => ({ ...r, models: parseModels(r.models) }));
}
@@@ -59,6 +59,37 @@ function createChatStore(s) {
await db.prepare('UPDATE exchange_chats SET updated_at = now() WHERE id = ? AND owner = ?').run(chatId, owner);
}
++ /** The chat's strict privacy switch: only offers that keep nothing and train on nothing may answer. */
++ async function setPrivacy(owner, chatId, { strictPrivacy }) {
++ await db.prepare('UPDATE exchange_chats SET strict_privacy = ?, updated_at = now() WHERE id = ? AND owner = ?')
++ .run(Boolean(strictPrivacy), chatId, owner);
++ }
++
++ /** The sealed conversation of a chat: the envelope only, never its plaintext (it never arrives here). */
++ async function sealGet(owner, chatId) {
++ const row = await db.prepare('SELECT chat_id, envelope, updated_at FROM exchange_chat_seals WHERE chat_id = ? AND owner = ?')
++ .get(String(chatId || ''), owner);
++ return row || null;
++ }
++
++ /** Replace a chat's sealed envelope. The owner is in the WHERE, so one account never writes another's chat. */
++ async function sealPut(owner, chatId, envelope) {
++ const chat = await db.prepare('SELECT id FROM exchange_chats WHERE id = ? AND owner = ? AND sealed = true').get(chatId, owner);
++ if (!chat) return null;
++ await db.prepare(`INSERT INTO exchange_chat_seals (chat_id, owner, envelope, updated_at) VALUES (?, ?, ?, now())
++ ON CONFLICT (chat_id) DO UPDATE SET envelope = excluded.envelope, updated_at = now()`)
++ .run(chatId, owner, envelope);
++ await db.prepare('UPDATE exchange_chats SET updated_at = now() WHERE id = ? AND owner = ?').run(chatId, owner);
++ return sealGet(owner, chatId);
++ }
++
++ /** A sealed chat: the chat row (the neutral title, the models) and its first envelope. */
++ async function createSealed({ owner, envelope, models = [] }) {
++ const chat = await create({ owner, models, title: 'Sealed chat', sealed: true });
++ await db.prepare('INSERT INTO exchange_chat_seals (chat_id, owner, envelope) VALUES (?, ?, ?)').run(chat.id, owner, envelope);
++ return chat;
++ }
++
async function remove(owner, chatId) {
await db.prepare('DELETE FROM exchange_chats WHERE id = ? AND owner = ?').run(chatId, owner);
}
@@@ -70,7 +101,7 @@ function createChatStore(s) {
return Number(row && row.n) || 0;
}
-- return { create, get, list, messages, append, rename, settings, clear, remove, countToday };
++ return { create, get, list, messages, append, rename, settings, clear, remove, countToday, setPrivacy, sealGet, sealPut, createSealed };
}
function createAdultStore(s) {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 4eb4bd3..7db77fb 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -158,6 +158,7 @@ function createPageRoutes(ctx) {
newHref: '/chat/new', newLabel: 'New chat', findAction: '/chat', findPlaceholder: 'Find a chat',
groups: thread.group(items.sort((a, b) => String(b.at).localeCompare(String(a.at))), (c) => c.at || Date.now()),
nav: [
++ { href: '/chat/new?sealed=1', label: 'A sealed chat', icon: 'shield' },
{ href: '/models', label: 'Models', icon: 'spark' },
{ href: '/api', label: 'API', icon: 'code' },
{ href: '/ai', label: 'AI in OpenVibe', icon: 'gear' },
@@@ -167,6 +168,7 @@ function createPageRoutes(ctx) {
function composerFor({ chat = null, models, problem = null, action = '/chat/send' }) {
const selected = chat && chat.models.length ? chat.models : models.slice(0, 1).map((m) => m.slug);
++ const strict = Boolean(chat && chat.strict_privacy);
const settings = `<details class="ovt-settings"><summary>Models, system prompt and temperature</summary>
<div class="ovt-settings-body">
${kit.chips({ name: 'models', label: 'Compare up to three models', options: modelOptions(models, selected), single: false })}
@@@ -174,6 +176,8 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
<textarea id="sys" name="system" rows="2" maxlength="4000">${esc((chat && chat.system_prompt) || '')}</textarea>
<label class="ex-label" for="temp">Temperature <span class="ex-muted">0 to 2</span></label>
<input id="temp" name="temperature" type="number" min="0" max="2" step="0.1" value="${chat && chat.temperature != null ? esc(chat.temperature) : ''}">
++<label class="ex-check ovk-check"><input type="checkbox" name="strict_privacy" value="1"${strict ? ' checked' : ''}> <span>Strict privacy: only offers that keep nothing and train on nothing</span></label>
++<p class="ex-muted ex-small">With strict privacy on, the router refuses when no private offer can answer, instead of falling back to one that keeps or trains on your prompt.</p>
</div></details>${problem ? kit.notice({ kind: 'error', text: problem }) : ''}`;
return thread.composer({
action, name: 'text', placeholder: 'Ask anything…', top: settings, maxlength: config.chat.maxChars,
@@@ -194,8 +198,13 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
const body = m.error
? thread.card({ tone: 'bad', title: 'Did not answer', text: m.error })
: thread.answer({ html: textOf(m.content), meta: [] });
++ const pv = m.meta && m.meta.privacy
++ ? views.answerPrivacy({ privacy: m.meta.privacy, privacy_detail: m.meta.privacy_detail })
++ : null;
const receipt = `<details class="ex-receipt"><summary>Receipt</summary><dl>
<dt>Offer</dt><dd>${esc(m.meta && m.meta.provider ? views.providerLabel({ provider: m.meta.provider }) : '—')}${m.meta && m.meta.answered_model ? ` · ${esc(m.meta.answered_model)}` : ''}</dd>
++<dt>Privacy</dt><dd>${pv ? `${kit.badge(pv.label, pv.kind)}${pv.facts ? ` · ${esc(pv.facts)}` : ''}` : '—'}</dd>
++${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''}
<dt>Price</dt><dd>${cost == null ? '—' : Number(cost) === 0 ? 'free' : `$${Number(cost).toFixed(6)}`}</dd>
<dt>Tokens</dt><dd>${m.meta && (m.meta.tokens_in != null || m.meta.tokens_out != null) ? `${Number(m.meta.tokens_in || 0).toLocaleString('en-US')} in · ${Number(m.meta.tokens_out || 0).toLocaleString('en-US')} out` : '—'}</dd>
<dt>Latency</dt><dd>${latency == null ? '—' : `${Number(latency).toLocaleString('en-US')} ms`}</dd>
@@@ -246,6 +255,114 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
return chats.list(owner(req));
}
++ /**
++ * A chat whose history is sealed with the person's Locker: the page carries the shell, never a turn. The
++ * browser opens the envelope and draws it (public/js/exchange.js).
++ */
++ function sealedPage({ req, res, chat = null, models = [] }) {
++ const subject = req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? req.viewer.subject : '';
++ return page(req, res, {
++ title: chat ? 'A sealed chat' : 'A new sealed chat', index: false,
++ class: 'ex-chat', app: true,
++ head: `<link rel="stylesheet" href="${require('openvibe-shared/serve').url('ov-locker.css')}"><script src="${require('openvibe-shared/serve').url('ov-locker.js')}" defer></script>`,
++ body: raw(views.sealedChat({ chat, models, networkUrl: config.networkUrl, subject })),
++ });
++ }
++
++ // ── Sealed chats: envelopes only ─────────────────────────
++ // The browser seals the conversation with the person's Locker (people-locked to them) and posts the envelope.
++ // Exchange stores it and nothing else: the plaintext of a sealed conversation never reaches this service, and
++ // a send carries the turns only for the run it makes (the model receives them; the database does not).
++ const sealedJson = express.json({ limit: '900kb' });
++ const envelopeOf = (body) => {
++ const envelope = body && typeof body.envelope === 'string' ? body.envelope.trim() : '';
++ if (!envelope || envelope.length > 700000 || !/^[A-Za-z0-9+/=]+$/.test(envelope)) return null;
++ const bytes = Buffer.from(envelope, 'base64');
++ return bytes.length >= 32 && bytes.length <= 520000 ? envelope : null;
++ };
++ const jsonRefusal = (res, status, error) => res.status(status).type('application/json').send(JSON.stringify({ error }));
++ const sameJson = (req, res) => {
++ if (!signedIn(req)) { jsonRefusal(res, 401, 'Sign in to OpenVibe first.'); return false; }
++ // A read is fine from this site; a write must come from it (never another origin).
++ if (req.method !== 'GET' && req.method !== 'HEAD' && !sameOrigin(req, config.baseUrl)) {
++ jsonRefusal(res, 403, 'That came from somewhere else.');
++ return false;
++ }
++ return true;
++ };
++
++ r.post('/chat/sealed/new', sealedJson, async (req, res) => {
++ if (!sameJson(req, res)) return;
++ const envelope = envelopeOf(req.body);
++ if (!envelope) return jsonRefusal(res, 422, 'A sealed chat needs the sealed envelope.');
++ const chat = await chats.createSealed({ owner: owner(req), envelope });
++ return res.status(201).json({ id: chat.id, url: `/chat/${chat.id}` });
++ });
++
++ r.get('/chat/:id/sealed', async (req, res) => {
++ if (!sameJson(req, res)) return;
++ const chat = await chats.get(owner(req), req.params.id);
++ if (!chat || !chat.sealed) return jsonRefusal(res, 404, 'No such sealed chat.');
++ const seal = await chats.sealGet(owner(req), chat.id);
++ return res.json({ id: chat.id, envelope: seal ? seal.envelope : null, updated_at: seal ? seal.updated_at : null, strict_privacy: Boolean(chat.strict_privacy) });
++ });
++
++ r.put('/chat/:id/sealed', sealedJson, async (req, res) => {
++ if (!sameJson(req, res)) return;
++ const envelope = envelopeOf(req.body);
++ if (!envelope) return jsonRefusal(res, 422, 'A sealed chat needs the sealed envelope.');
++ const seal = await chats.sealPut(owner(req), req.params.id, envelope);
++ if (!seal) return jsonRefusal(res, 404, 'No such sealed chat.');
++ return res.json({ ok: true, updated_at: seal.updated_at });
++ });
++
++ r.post('/chat/:id/sealed/send', sealedJson, async (req, res) => {
++ if (!sameJson(req, res)) return;
++ const chat = await chats.get(owner(req), req.params.id);
++ if (!chat || !chat.sealed) return jsonRefusal(res, 404, 'No such sealed chat.');
++ const body = req.body || {};
++ const model = String(body.model || '').slice(0, 256);
++ if (!model) return jsonRefusal(res, 422, 'Pick a model first.');
++ const messages = (Array.isArray(body.messages) ? body.messages : []).slice(-80)
++ .filter((m) => m && (m.role === 'user' || m.role === 'assistant' || m.role === 'system') && typeof m.content === 'string')
++ .map((m) => ({ role: m.role, content: m.content.slice(0, 32000) }));
++ if (!messages.length || !messages.some((m) => m.role === 'user')) return jsonRefusal(res, 422, 'Write something to send.');
++ const temperature = body.temperature == null || body.temperature === '' ? null : Math.max(0, Math.min(2, Number(body.temperature)));
++ const strict = body.strict_privacy === true || body.strict_privacy === '1' || body.strict_privacy === 'on';
++ try {
++ const out = await ai.chat({ model, messages, temperature, subject: owner(req), preset: strict ? 'private' : null });
++ return res.json({
++ answer: {
++ content: out.content || '(no text)', model: out.model, provider: out.provider, run_id: out.run_id,
++ cost_usd: out.cost_usd, tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms,
++ why: out.why, privacy: out.privacy, privacy_detail: out.privacy_detail, preset: out.preset,
++ },
++ });
++ } catch (err) {
++ return res.status(err.status && err.status >= 400 && err.status < 600 ? err.status : 502)
++ .json({ error: err.message || 'The model did not answer.', code: err.code || null });
++ }
++ });
++
++ /** The person's strict-privacy switch on their API keys (an account "default" preset in OpenVibe.AI). */
++ r.post('/privacy/keys', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/api')}`);
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const on = Boolean(req.body && (req.body.strict === '1' || req.body.strict === 'on'));
++ let keyPrivacy = { on, available: true };
++ try {
++ const out = await ai.setKeyPrivacy(owner(req), on, { networkToken: (req.viewer && req.viewer.token) || null });
++ if (out && out.projects) {
++ keyPrivacy.note = `Set on your account${out.projects_set ? ` and ${out.projects_set} of your ${out.projects} project${out.projects === 1 ? '' : 's'}` : ''}: their keys now use the private preset unless a request names another.`
++ + (out.projects_failed && out.projects_failed.length ? ` ${out.projects_failed.length} project${out.projects_failed.length === 1 ? '' : 's'} could not be written.` : '');
++ }
++ } catch (err) {
++ keyPrivacy = { on: !on, available: true, error: `The switch could not be saved: ${err.message || 'OpenVibe.AI did not answer'}` };
++ }
++ const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
++ return page(req, res, { title: 'The API', index: false, body: raw(views.apiPage({ catalog, usage, viewer: req.viewer, keyPrivacy })) });
++ });
++
r.get('/chat', async (req, res) => {
const list = await loadChats(req);
if (!list.length) return signInIfGuest(req, res, async () => {
@@@ -262,13 +379,16 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
r.get('/chat/new', async (req, res) => signInIfGuest(req, res, async () => {
const [list, cat] = await Promise.all([chats.list(owner(req)), chatCatalog()]);
++ if (req.query.sealed === '1') return sealedPage({ req, res, models: cat.models });
return chatPage({ req, res, chats: list, models: cat.models });
}));
r.get('/chat/:id', async (req, res) => signInIfGuest(req, res, async () => {
const chat = await chats.get(owner(req), req.params.id);
if (!chat) return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'chat', title: 'No such chat', text: 'It may have been deleted.', action: { href: '/chat', label: 'Your chats' } })}</div>`) }, 404);
-- const [messages, list, cat] = await Promise.all([chats.messages(owner(req), chat.id), chats.list(owner(req)), chatCatalog()]);
++ const [list, cat] = await Promise.all([chats.list(owner(req)), chatCatalog()]);
++ if (chat.sealed) return sealedPage({ req, res, chat, models: cat.models });
++ const messages = await chats.messages(owner(req), chat.id);
return chatPage({ req, res, chat, chats: list, messages, models: cat.models });
}));
@@@ -284,9 +404,13 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
const chosen = wanted.length ? wanted : models.slice(0, 1).map((m) => m.slug);
const system = body.system ? String(body.system).slice(0, 4000) : null;
const temperature = body.temperature === '' || body.temperature == null ? null : Math.max(0, Math.min(2, Number(body.temperature)));
++ const strict = Boolean(body.strict_privacy && ['1', 'on', 'true', 'yes'].includes(String(body.strict_privacy)));
++ const preset = strict ? 'private' : null;
let chat = chatId ? await chats.get(owner(req), chatId) : null;
if (chatId && !chat) return page(req, res, { title: 'Not found', index: false, body: raw(kit.empty({ icon: 'chat', title: 'No such chat', text: 'It may have been deleted.', action: { href: '/chat', label: 'Your chats' } })) }, 404);
++ // A sealed chat is written by its own page: its turns never come through this form.
++ if (chat && chat.sealed) return res.redirect(303, `/chat/${chat.id}`);
const uncensored = chosen.map((s) => models.find((m) => m.slug === s)).filter((m) => m && m.uncensored);
if (uncensored.length && !(await adult.confirmed(owner(req)))) {
const list = await loadChats(req);
@@@ -307,8 +431,12 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
}
const isNew = !chat;
-- if (!chat) chat = await chats.create({ owner: owner(req), models: chosen, title: text.replace(/\s+/g, ' ').slice(0, 60) });
-- else await chats.settings(owner(req), chat.id, { models: chosen, systemPrompt: system, temperature });
++ if (!chat) chat = await chats.create({ owner: owner(req), models: chosen, title: text.replace(/\s+/g, ' ').slice(0, 60), strictPrivacy: strict });
++ else {
++ await chats.settings(owner(req), chat.id, { models: chosen, systemPrompt: system, temperature });
++ await chats.setPrivacy(owner(req), chat.id, { strictPrivacy: strict });
++ chat.strict_privacy = strict;
++ }
if (!isNew && (system !== chat.system_prompt || temperature !== chat.temperature)) {
// settings stored above
}
@@@ -329,12 +457,13 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
{ role: 'user', content: text },
];
try {
-- const out = await ai.chat({ model: modelId, messages, temperature, subject: owner(req) });
++ const out = await ai.chat({ model: modelId, messages, temperature, subject: owner(req), preset });
await chats.append(owner(req), chat.id, {
role: 'assistant', model: slug, content: out.content || '(no text)',
meta: {
run_id: out.run_id, provider: out.provider, answered_model: out.model, cost_usd: out.cost_usd,
tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms, why: out.why,
++ privacy: out.privacy, privacy_detail: out.privacy_detail, preset: out.preset,
},
});
} catch (err) {
@@@ -379,11 +508,19 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
// ── API ──────────────────────────────────────────────────
r.get('/api', async (req, res) => {
const [catalog, usage] = await Promise.all([catalogOf(), ai.usage(30)]);
++ // The strict-privacy switch on a person's keys: their account's "default" preset in AI.
++ let keyPrivacy = null;
++ if (signedIn(req)) {
++ const presets = typeof ai.presetsFor === 'function' ? await ai.presetsFor(owner(req)) : null;
++ keyPrivacy = Array.isArray(presets)
++ ? { available: true, on: presets.some((p) => p && p.name === 'default' && p.policy && p.policy.data_policy === 'private') }
++ : { available: false };
++ }
return page(req, res, {
-- index: true, cache: PUBLIC_CACHE, agentLayer: true,
++ index: true, cache: signedIn(req) ? null : PUBLIC_CACHE, agentLayer: true,
title: 'The API',
description: 'One OpenAI- and Anthropic-compatible endpoint for every model on OpenVibe.Exchange: keys, model ids, usage.',
-- body: raw(views.apiPage({ catalog, usage })),
++ body: raw(views.apiPage({ catalog, usage, viewer: req.viewer, keyPrivacy })),
});
});
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 038ec26..3e5e277 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -21,6 +21,9 @@ const TABLES = [
{ table: 'exchange_offers', subject: 'from_subject', value: (usr) => usr, file: 'offers.json', erase: 'delete' },
{ table: 'exchange_chats', subject: 'owner', value: (usr) => usr, file: 'chats.json', erase: 'delete' },
{ table: 'exchange_chat_messages', subject: 'owner', value: (usr) => usr, file: 'chat-messages.json', erase: 'delete' },
++ // A sealed chat's envelope is exported as it is stored (base64 ciphertext: only the person's Locker opens it) and
++ // erased with the chat it belongs to. Its plaintext was never here.
++ { table: 'exchange_chat_seals', subject: 'owner', value: (usr) => usr, file: 'chat-seals.json', erase: 'delete' },
{ table: 'exchange_adult_confirmations', subject: 'subject', value: (usr) => usr, file: 'adult-confirmation.json', erase: 'delete' },
// The companions (2026-11-06): a person's characters, their chats and the messages in them are theirs,
// exactly like the chats above. The messages go first, then the chats, then the characters: a character
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex de6db6a..d86963c 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -66,20 +66,38 @@ function listSaid(xs) {
}
/**
-- * The privacy label the design asks for, from facts only: a community machine sees the prompt; zero retention
-- * and no training is Private; no training alone is said as that. "Anonymized" is never claimed, because no
-- * offer here anonymises the request.
++ * The privacy label the design asks for, from facts only. AI's offer carries the label it read from the provider's
++ * own terms (`offer.privacy`, the same function the router filters on): Private when it keeps nothing and trains on
++ * nothing, Anonymized when it declares a policy that is weaker, Community for a person's machine. An offer that
++ * declares nothing is Standard. "No training" is said as that when the provider promises only that.
*/
function privacyOf(offer) {
if (!offer) return null;
if (offer.trust === 'community') return { label: 'Community', kind: 'accent', short: 'Community' };
++ const label = offer.privacy && offer.privacy.label;
++ if (label === 'private') return { label: 'Private', kind: 'ok', short: 'Private' };
const p = offer.data_policy;
if (p && p.anonymized === true) return { label: 'Anonymized', kind: 'accent', short: 'Anonymized' };
-- if (p && p.trains === false && p.retention_days === 0) return { label: 'Private', kind: 'ok', short: 'Private' };
if (p && p.trains === false) return { label: 'No training', kind: 'ok', short: 'No training' };
++ if (label === 'anonymized') return { label: 'Anonymized', kind: 'accent', short: 'Anonymized' };
return { label: 'Standard', kind: '', short: 'Standard' };
}
++/** The label of an answer, from the run's own explain (`privacy` in AI). */
++const ANSWER_LABELS = { private: ['Private', 'ok'], anonymized: ['Anonymized', 'accent'], community: ['Community', 'accent'] };
++function answerPrivacy(result) {
++ if (!result || !result.privacy) return null;
++ const [name, kind] = ANSWER_LABELS[result.privacy] || ['Standard', ''];
++ const d = result.privacy_detail || {};
++ const facts = [];
++ if (d.retention === 'none') facts.push('no retention');
++ if (d.training === false) facts.push('no training');
++ if (d.trust === 'own_machine') facts.push('your own machine');
++ if (d.trust === 'own_deployment') facts.push('OpenVibe’s own hardware');
++ if (d.trust === 'community') facts.push('a community machine sees the prompt');
++ return { label: name, kind, facts: facts.join(' · ') || (d.known === false ? 'the offer declared no policy' : '') };
++}
++
const freeTier = (o) => Boolean(o && o.free_tier === true);
function badgesOf(m, best) {
@@@ -322,7 +340,38 @@ function presetsBox() {
<p>${esc(text)}</p>
</article>`).join('');
return `<div class="ex-presets">${cards}</div>
--<p class="ex-prose">These four aliases work today. Saved presets (<code>model: "@preset/cheapest"</code>) add your own floor, ceiling, quantization, region, data policy and <code>max_price</code>; they arrive with the router’s presets.</p>`;
++<p class="ex-prose">These four aliases work today. A saved preset (<code>model: "@preset/private"</code>) is the router’s floor, ceiling, quantization, region and data policy written down once. The private preset is the strict one: only offers that keep nothing and train on nothing, or OpenVibe’s own deployments, may answer, and the router refuses rather than fall back.</p>`;
++}
++
++// ── A sealed chat ──────────────────────────────────────────
++/**
++ * The shell of a chat whose history is sealed with the person's Locker. The server never renders a turn: the page
++ * carries no conversation, the browser opens the envelope and draws it (public/js/exchange.js). Only the chat id
++ * and the model list are here, and neither is the history.
++ */
++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' })] })}
++<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>
++<p class="ex-sealed-status" data-sealed-status>Opening your Locker…</p>
++<div class="ex-sealed-turns" data-sealed-turns></div>
++<form class="ovk-form ex-sealed-form" data-sealed-form hidden>
++<label class="ex-label" for="sealed-text">Your message</label>
++<textarea id="sealed-text" name="text" rows="4" maxlength="8000" required placeholder="Ask anything…"></textarea>
++<div class="ex-sealed-row">
++<label class="ex-label" for="sealed-model">Model</label>
++<select id="sealed-model" name="model">${options}</select>
++<label class="ex-check ovk-check"><input type="checkbox" name="strict_privacy" value="1" checked> <span>Strict privacy</span></label>
++</div>
++<div class="ex-try-row">${kit.button({ label: 'Send', type: 'submit' })}<span class="ex-muted">Sealed on this device before it leaves the page.</span></div>
++<p class="ovk-msg" data-sealed-msg aria-live="polite"></p>
++</form>
++</div>
++<p class="ex-sealed-note"><strong>Said plainly:</strong> OpenVibe stores only the sealed envelope and cannot read it. The model still receives the text of the turn you send, and the earlier turns your browser sends with it, while it answers; that is privacy at rest, not from the model. Your passphrase, recovery words and keys never leave this tab.</p>
++</div>`;
}
function tryBox(model, viewer, { result, problem, text, adult }) {
@@@ -353,8 +402,10 @@ function policyText(p) {
}
function resultCard(result) {
++ const privacy = answerPrivacy(result);
const facts = [
['Offer', result.provider ? (INTERNAL.test(String(result.provider)) ? 'OpenVibe' : result.provider) : '–'],
++ ['Privacy', privacy ? `${privacy.label}${privacy.facts ? ` · ${privacy.facts}` : ''}` : '–'],
['Cost', result.cost_usd == null ? '–' : `$${Number(result.cost_usd).toFixed(6)}`],
['Latency', result.latency_ms == null ? '–' : ms(result.latency_ms)],
['Tokens', `${count(result.tokens_in)} in · ${count(result.tokens_out)} out`],
@@@ -368,11 +419,24 @@ function resultCard(result) {
}
// ── API ────────────────────────────────────────────────────
--function apiPage({ catalog, usage }) {
++function apiPage({ catalog, usage, viewer = null, keyPrivacy = null }) {
const models = chatModels(catalog).filter((m) => !m.uncensored).slice(0, 12);
const rows = models.map((m) => ({ model: `<code>${esc(m.slug)}</code>`, context: m.context || null }));
const byDay = (usage.by_day || []).slice(-14).reverse();
const usageRows = byDay.map((d) => ({ day: d.day, requests: count(d.requests), tokens: shortCount((d.tokens_in || 0) + (d.tokens_out || 0)), cost: d.cost_usd == null ? '–' : `$${Number(d.cost_usd) < 0.1 ? Number(d.cost_usd).toFixed(4) : Number(d.cost_usd).toFixed(2)}` }));
++ const signedIn = viewer && viewer.kind === 'user' && viewer.subject;
++ const switchBox = !signedIn
++ ? `<p class="ex-prose">Sign in to set strict privacy on your keys.</p>`
++ : (keyPrivacy && keyPrivacy.available === false
++ ? `<p class="ex-prose">The strict-privacy switch is not available right now. Requests can still name <code>model: "@preset/private"</code>.</p>`
++ : `<form class="ovk-form ex-switch" method="post" action="/privacy/keys">
++<input type="hidden" name="next" value="/api">
++${keyPrivacy && keyPrivacy.error ? kit.notice({ kind: 'error', text: keyPrivacy.error }) : ''}
++<label class="ex-check ovk-check"><input type="checkbox" name="strict" value="1"${keyPrivacy && keyPrivacy.on ? ' checked' : ''}> <span>Strict privacy on my keys</span></label>
++${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.note }) : ''}
++<p class="ex-prose">Every request one of your keys makes that names no preset then uses the private preset: only an offer that keeps nothing and trains on nothing, or OpenVibe’s own deployment, may answer. When none can, the router refuses with a plain message rather than falling back to an offer that keeps or trains on the prompt. The switch is your account’s and your own projects’ <code>default</code> preset in OpenVibe.AI, so their keys use it too.</p>
++<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' })] })}
${panel({ title: 'One endpoint', body: `
@@@ -381,6 +445,8 @@ ${kit.snippet({ code: `curl ${ENDPOINT} \\\n -H "Authorization: Bearer $OPENVIB
<h3 class="ex-h3">Keys</h3>
<p class="ex-prose">Keys are OpenVibe.Network developer keys: make one, name its scope, and revoke it any time. They are shown once and never stored here.</p>
<p>${kit.button({ href: 'https://openvibe.network/developers', label: 'Make a key on Network' })}</p>
++<h3 class="ex-h3" id="strict">Strict privacy</h3>
++${switchBox}
<h3 class="ex-h3">Model ids</h3>
<p class="ex-prose"><code>openvibe/auto</code> lets the router pick by your floor, ceiling and policy, and the answer says which model ran and why. An exact id pins one artifact. Bring your own provider key and those requests pay the provider directly, at no fee from us.</p>
${kit.table({ columns: [{ key: 'model', label: 'Model id', primary: true, html: true }, { key: 'context', label: 'Context', num: true }], rows, label: 'Model ids', stack: false })}
@@@ -457,7 +523,7 @@ function uncensoredPage({ catalog, adult, signedIn }) {
const gate = !signedIn
? kit.section({ title: 'Adults only', body: `<p class="ex-prose">The uncensored tier is for adults. Sign in first (the confirmation sits on your account), then confirm you are 18 or over.</p><p>${kit.button({ href: '/auth/login?next=%2Funcensored', label: 'Sign in with OpenVibe' })}</p>` })
: !adult
-- ? kit.section({ title: 'Confirm you are 18 or over', body: `<p class="ex-prose">Uncensored models are abliterated builds. They will answer what a filtered model refuses. The floor below applies to every request, and a breach ends the account.</p>${floorText()}<form method="post" action="/uncensored/confirm" class="ovk-form ex-confirm"><label class="ex-check"><input type="checkbox" name="adult" value="yes" required> <span>I am 18 or over and I accept the floor.</span></label>${kit.button({ label: 'Confirm', type: 'submit' })}</form>` })
++ ? kit.section({ title: 'Confirm you are 18 or over', body: `<p class="ex-prose">Uncensored models are abliterated builds. They will answer what a filtered model refuses. The floor below applies to every request, and a breach ends the account.</p>${floorText()}<form method="post" action="/uncensored/confirm" class="ovk-form ex-confirm"><label class="ex-check ovk-check"><input type="checkbox" name="adult" value="yes" required> <span>I am 18 or over and I accept the floor.</span></label>${kit.button({ label: 'Confirm', type: 'submit' })}</form>` })
: '';
return `<div class="ex-wrap ex-page">
${kit.head({ title: 'Uncensored', sub: 'Abliterated models, for adults. The same floor, always.', actions: [kit.badge('18+', 'bad')] })}
@@@ -591,4 +657,4 @@ ${panel({ title: 'Where those things live now', body: `<ul class="ex-list">
</div>`;
}
--module.exports = { home, modelsPage, modelPage, apiPage, sellPage, uncensoredPage, aiPage, statusPage, retired, privacyOf, providerLabel, familyLabel, chatModels, ENDPOINT };
++module.exports = { home, modelsPage, modelPage, apiPage, sellPage, uncensoredPage, aiPage, statusPage, retired, sealedChat, privacyOf, answerPrivacy, providerLabel, familyLabel, chatModels, ENDPOINT };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex e20d0fb..8b0f70d 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -28,6 +28,7 @@ const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.accoun
const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
const PASSPORT = '{"reads":[],"writes":[],"spends":[],"contacts":[]}';
++const SEALED = Buffer.alloc(96, 7).toString('base64');
const MANIFEST = '{"name":"x","entry":"y"}';
async function startNetworkStub() {
@@@ -99,6 +100,7 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
const bCompanion = await t.ctx.companionChats.create({ owner: B, characterId: bCharacter.id, title: 'Bob’s chat' });
await t.ctx.companionChats.append({ owner: B, chatId: bCompanion.id, role: 'user', content: 'Bob’s words', turnId: 'ext_2' });
++ const sealedChat = await t.ctx.chats.createSealed({ owner: A, envelope: SEALED });
// The memory-note index, a lorebook with an entry, and a Locker row: stages 3 and 4, exported and erased.
const noteId = `exn_${ids.ulid()}`;
const m2Id = `mem_${ids.ulid()}`;
@@@ -125,12 +127,13 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
assert.strictEqual(part.auth, 'Bearer tok_exchange');
assert.strictEqual(part.body.subject, A);
assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(),
-- ['adult-confirmation.json', 'characters.json', 'chat-messages.json', 'chats.json', 'companion-chats.json', 'companion-locks.json', 'companion-messages.json', 'companion-notes.json', 'installs.json', 'listings.json', 'lorebook-entries.json', 'lorebooks.json', 'offers.json', 'sandbox-trials.json']);
++ ['adult-confirmation.json', 'characters.json', 'chat-messages.json', 'chat-seals.json', 'chats.json', 'companion-chats.json', 'companion-locks.json', 'companion-messages.json', 'companion-notes.json', 'installs.json', 'listings.json', 'lorebook-entries.json', 'lorebooks.json', 'offers.json', 'sandbox-trials.json']);
const all = JSON.stringify(part.body);
assert.ok(all.includes('Alice\'s listed theme'));
assert.ok(all.includes('Alice\'s draft theme'));
assert.ok(!all.includes('Bob\'s listed theme'), 'nobody else\'s row');
assert.ok(all.includes('A question for the models') && all.includes('What is a mutex?') && all.includes('A lock.'), 'the chat and its messages');
++ assert.ok(all.includes(SEALED), 'a sealed chat’s envelope is exported as it is stored');
assert.strictEqual(part.body.files.find((f) => f.name === 'installs.json').content[0].id, install);
assert.strictEqual(part.body.files.find((f) => f.name === 'sandbox-trials.json').content[0].id, trial);
assert.match(all, /I would like Bob/);
@@@ -162,6 +165,8 @@ async function seedListing(db, { owner, title, status = 'listed', kind = 'theme'
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_offers WHERE id = $1', [offer])), 0);
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_chats WHERE owner = $1', [A])), 0, 'the chat is erased');
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_chat_messages WHERE owner = $1', [A])), 0, 'its messages are erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_chat_seals WHERE owner = $1', [A])), 0, 'the sealed envelope is erased with the chat');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_chats WHERE id = $1', [sealedChat.id])), 0);
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_adult_confirmations WHERE subject = $1', [A])), 0, 'the confirmation is erased');
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_characters WHERE owner = $1', [A])), 0, 'the character is erased');
assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_companion_chats WHERE owner = $1', [A])), 0, 'the companion chat is erased');
ddiff --git a/test/private-chat.test.js b/test/private-chat.test.js
nnew file mode 100644
iindex 0000000..af1bf99
---- /dev/null
++++ b/test/private-chat.test.js
@@@ -0,0 +1,178 @@
++'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');
++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 });
++ ai.presets = [];
++ ai.presetCalls = [];
++ ai.presetsFor = async () => ai.presets;
++ ai.setKeyPrivacy = async (subject, on) => {
++ ai.presetCalls.push({ subject, on });
++ 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);
++ });
++
++ 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 () => {
++ 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');
++ await t.get('/privacy/keys', { as: alice, method: 'POST', form: {} });
++ assert.deepStrictEqual(ai.presetCalls[ai.presetCalls.length - 1], { subject: alice.subject, on: false });
++ });
++ } finally { await t.close(); }
++ done();
++})();