openvibers/openvibe.ai Commits

AI: private AI — the label, the plain refusal, and a key's default preset (#326)

OP OpenVibers committed 7 days ago · 21a15d1281 · 9 files, +431 −20

* AI: private AI — the label, the plain refusal, and a key's default preset

The privacy label is now read in one place for every surface: zero retention
and no training is private, a community machine is community, an undeclared
policy is anonymized, and the host's own deployment (metadata.local) is
private too. The catalog offers carry it beside the data policy, and the run's
own explain carries it, so an answer's receipt and the Exchange show the same
label the router used.

A preset that asks for a data policy no offer meets now refuses with
policy.privacy_unavailable and a plain message, before any provider is called:
the router never falls back to an offer that keeps or trains on the prompt.

A developer key that names no preset uses its owner's "default" preset (the
Exchange's strict-privacy switch on a key). A service may set and read a
person's or a project's presets with on_behalf_of, and a run resolves a preset
named without a policy by name, so the Exchange asks for "private" and gets
the one definition of it.

* Presets: only a first-party service names whom it acts for

on_behalf_of was checked only for a person's own token: an app's developer key could set or read another person's
presets (for example drop their strict privacy default), and a person could name any project. A caller with a kind
(user, app) may now name only an owner it already is; a service token keeps acting for the person it names.

Changes

ddiff --git a/docs/private-ai.md b/docs/private-ai.md
nnew file mode 100644
iindex 0000000..e96eff2
---- /dev/null
++++ b/docs/private-ai.md
@@@ -0,0 +1,50 @@
++# Private AI (strict privacy)
++
++The plan of 2026-11-06, "Product lines and features", item 2. OpenVibe.Exchange's chat (`/chat`) and the Exchange
++API both use one definition of private here; nothing in Exchange re-implements it.
++
++## The label of an answer
++
++`server/exchange/privacy.js` reads the label off the offer that answered, never guesses:
++
++| label | when |
++| --- | --- |
++| `private` | the provider's own terms say zero retention **and** no training, or the offer is a person's own machine (a Node credential), or it is the host's own deployment (`metadata.local`). |
++| `community` | the offer runs on a community machine (a person's hardware, Node's "Offer to the network"): that machine sees the prompt. |
++| `anonymized` | everything else, including a provider that declares no policy (`known: false` says so). |
++
++The label is written on the run's `explain.privacy` (every surface that reads a run shows it), on the receipt
++(`privacy`), and in the OpenAI- and Anthropic-compatible responses (`x_openvibe.privacy` / `privacy`). The Exchange
++catalog offers carry it too (`privacy`), beside the provider's declared `data_policy`.
++
++## Strict privacy: the private preset
++
++`@preset/private` is a built-in (`server/exchange/presets.js`): `data_policy: "private"`, `community: false`. The
++router (`server/providers/index.js` → `server/exchange/constraints.js`) removes every offer that does not meet it,
++naming the rule for each. When none is left it **refuses**:
++
++- code `policy.privacy_unavailable`, HTTP 503, plain message: "Strict privacy is on: no offer that keeps nothing
++ and trains on nothing can answer right now… the router does not fall back to one."
++- nothing is called: the refusal is before any provider.
++
++`node_modules` in Exchange is never involved: the Exchange asks for the preset by name (`options.preset: { name:
++"private" }` in `/api/v1/runs`), and `server/runs.js` resolves it to the built-in policy. A preset named with a
++policy inline still wins, so a caller can add a region or a cap on top.
++
++## The switch on a key
++
++A developer key (an app token) that names no preset uses its owner's preset named `default`, when one exists
++(`resolveRequest` in `server/exchange/presets.js`). That is the strict-privacy switch on the Exchange's `/api` page:
++it saves `{ data_policy: "private", community: false }` as the person's `default` preset through
++`POST /api/v1/presets` with `on_behalf_of`, and on each project they own (Network's `GET /api/v1/projects`, filtered
++to `owner.id === subject`), because a project's key acts as the project. A service's own requests are untouched:
++only the key's owner's choice. A project owner may also save the preset directly:
++`on_behalf_of: { type: "project", id: "prj_…" }`.
++
++## Sealed history
++
++Exchange stores only a `locker.envelope.v1` envelope (`exchange_chat_seals`), people-locked to the person with
++`openvibe-shared/ov-locker.js`: the browser seals and opens the conversation, so OpenVibe cannot read it at rest.
++The model still receives the turn (and the earlier turns the browser sends with it) while it answers: private at
++rest, not from the model, and the page says that plainly. Account export carries the envelope as stored (base64
++ciphertext) and deletion removes it with the chat.
ddiff --git a/server/api/exchange.js b/server/api/exchange.js
iindex 4e9bd56..77d73b2 100644
---- a/server/api/exchange.js
++++ b/server/api/exchange.js
@@@ -62,19 +62,61 @@ function exchangeRouter({ db, registry, pool, env = process.env, clock = { now:
const owners = presets.ownersOf(req.principal);
return owners[0] || null;
};
++ /**
++ * A service acting for a person or a project (the Exchange's own switch on a person's keys): `on_behalf_of`
++ * names them in the body or the query, as `{ type, id }` or as a bare id. A person's own token never names
++ * someone else.
++ */
++ const ownerOfRequest = (req, b) => {
++ const raw = (b && b.on_behalf_of) || req.query.on_behalf_of;
++ if (!raw) return null;
++ const id = typeof raw === 'object' ? raw.id : raw;
++ const type = typeof raw === 'object' && raw.type ? String(raw.type) : (/^prj_/.test(String(id || '')) ? 'project' : 'user');
++ if (!id) throw new AiError(422, 'ai.invalid', 'on_behalf_of must name a person (usr_…) or a project (prj_…)');
++ if (type === 'project') {
++ if (!/^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id))) throw new AiError(422, 'ai.invalid', 'on_behalf_of must name a project (prj_…)');
++ return mayActFor(req, { type: 'project', id: String(id) });
++ }
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id))) throw new AiError(422, 'ai.invalid', 'on_behalf_of must name a person (usr_…)');
++ return mayActFor(req, { type: 'user', id: String(id) });
++ };
++
++ /**
++ * Only a first-party service token (requireCap's principal has no kind) names whom it acts for. A person, an
++ * app or a developer key may name only an owner it already is (presets.ownersOf): never another person, and
++ * never a project it merely knows the id of.
++ */
++ const mayActFor = (req, target) => {
++ const p = req.principal || {};
++ if (!p.kind) return target;
++ const mine = presets.ownersOf(p);
++ if (mine.some((o) => o.type === target.type && o.id === target.id)) return target;
++ throw new AiError(403, 'capability.denied', 'a preset belongs to its own person or project; this token may not name that one');
++ };
++
++ /** Every preset a target may name: its own rows first, then the built-in ones it does not shadow. */
++ const presetsForOwner = async (owner) => {
++ const own = await presets.list(owner);
++ const seen = new Set(own.map((p) => p.name));
++ return [...own, ...presets.builtins().filter((x) => !seen.has(x.name))];
++ };
if (presets) {
-- r.get('/api/v1/presets', read, wrap(async (req) => ({ presets: await presets.visible(req.principal) })));
++ r.get('/api/v1/presets', read, wrap(async (req) => {
++ const owner = ownerOfRequest(req);
++ if (owner) return { presets: await presetsForOwner(owner) };
++ return { presets: await presets.visible(req.principal) };
++ }));
r.post('/api/v1/presets', write, wrap(async (req, res) => {
const b = body(req);
-- const owner = ownerOf(req);
++ const owner = ownerOfRequest(req, b) || ownerOf(req);
if (!owner) throw new AiError(422, 'ai.invalid', 'a preset belongs to an account or a project');
const preset = await presets.put({ owner, name: b.name, policy: b.policy });
res.status(201);
return { preset };
}));
r.delete('/api/v1/presets/:name', write, wrap(async (req) => {
-- const owner = ownerOf(req);
++ const owner = ownerOfRequest(req, body(req)) || ownerOf(req);
if (!owner) throw new AiError(422, 'ai.invalid', 'a preset belongs to an account or a project');
return presets.remove({ owner, name: req.params.name });
}));
ddiff --git a/server/exchange/catalog.js b/server/exchange/catalog.js
iindex 878fe09..b45c776 100644
---- a/server/exchange/catalog.js
++++ b/server/exchange/catalog.js
@@@ -15,6 +15,7 @@
const rates = require('../providers/rate-cards');
const upstream = require('../providers/upstream');
const observed = require('./observed');
++const privacy = require('./privacy');
const { allowsOffer } = require('../free-tier');
const FAMILY_BY_PREFIX = [
@@@ -87,7 +88,9 @@ function policyOf(provider) {
return { trains, retention_days: retention, region, anonymized };
}
--const isCommunity = (provider) => Boolean(provider && provider.metadata && (provider.metadata.local === true || provider.metadata.community === true));
++// A community machine is a person's machine offering capacity (Node's "Offer to the network"). The host's own
++// local deployments (metadata.local) are OpenVibe's own hardware, not a stranger's: they are labelled private.
++const isCommunity = (provider) => Boolean(provider && provider.metadata && provider.metadata.community === true);
function capabilitiesOf(row) {
const s = (row && row.supports) || {};
@@@ -163,6 +166,9 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
price_source: cards && cards.out.source && !String(cards.out.source).startsWith('urn:openvibe:ai:unverified:') ? cards.out.source : null,
price_checked_at: cards && cards.out.verified_at && cards.out.verified_at !== rates.UNKNOWN_DATE ? cards.out.verified_at : null,
data_policy: policyOf(provider),
++ // The privacy label of this offer, from the same facts the router uses (server/exchange/privacy.js):
++ // Private (zero retention, no training, or the host's own deployment), Anonymized, Community.
++ privacy: privacy.view(privacy.labelFor({ record: provider })),
updated_at: row.updated_at || provider.updated_at || null,
};
if (!groups.has(slug)) {
ddiff --git a/server/exchange/constraints.js b/server/exchange/constraints.js
iindex 00089ab..71abf1f 100644
---- a/server/exchange/constraints.js
++++ b/server/exchange/constraints.js
@@@ -99,4 +99,20 @@ function refusal({ policy, excluded = [], cheapest = null }) {
return { code: 'ai.max_price_exceeded', detail, max_price: cap, lowest_price: lowest, excluded };
}
--module.exports = { applyConstraints, reasonFor, refusal, regionOk, tierRank };
++/**
++ * The refusal of a strict data policy: a preset asked for a privacy class and no offer has it. The request is
++ * refused here, before any provider is called, and the router never falls back to an offer that keeps or trains
++ * on the prompt (the plan of 2026-11-06, "Private AI": "the router refuses rather than falls back").
++ */
++function privacyRefusal({ policy, excluded = [] } = {}) {
++ const wanted = (policy && policy.data_policy) || 'private';
++ const leftPrivate = excluded.filter((e) => /^data_policy|^community_not_allowed/.test(e.reason || ''));
++ const detail = wanted === 'private'
++ ? '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. '
++ + 'Try again later, or turn strict privacy off.'
++ : `Strict privacy is on: no offer with the data policy "${wanted}" can answer right now. Nothing was sent to a provider that does not meet it.`;
++ return { code: 'policy.privacy_unavailable', detail, data_policy: wanted, excluded: leftPrivate, excluded_all: excluded };
++}
++
++module.exports = { applyConstraints, reasonFor, refusal, privacyRefusal, regionOk, tierRank };
ddiff --git a/server/exchange/presets.js b/server/exchange/presets.js
iindex ca3d218..fa2d8d0 100644
---- a/server/exchange/presets.js
++++ b/server/exchange/presets.js
@@@ -173,6 +173,18 @@ function createPresets({ db, clock = { now: () => Date.now() }, log = console }
throw new AiError(404, 'ai.preset_not_found', `no preset ${wanted}: GET /api/v1/presets lists the ones you may name`);
}
++ /** Like resolve(), but a name nobody has is null: the default preset of a key is optional, not a refusal. */
++ async function find({ principal, owners, name }) {
++ const wanted = checkName(name);
++ for (const owner of (owners && owners.length ? owners : ownersOf(principal))) {
++ const row = decode(await select.get(owner.type, owner.id, wanted));
++ if (row) return row;
++ }
++ const builtin = BUILTINS.find((b) => b.name === wanted);
++ if (builtin) return { name: builtin.name, description: builtin.description, scope: 'builtin', builtin: true, policy: { ...builtin.policy } };
++ return null;
++ }
++
/** Store (or replace) one of the owner's presets. A built-in name cannot be overwritten: it is not the owner's. */
async function put({ owner, name, policy }) {
if (!owner || !owner.id) throw new AiError(422, 'ai.invalid', 'a preset belongs to an account or a project');
@@@ -193,11 +205,13 @@ function createPresets({ db, clock = { now: () => Date.now() }, log = console }
return { name: key, removed: r.changes > 0 };
}
-- return { list, builtins, visible, resolve, put, remove: remove_, normalizePolicy, ownersOf };
++ return { list, builtins, visible, resolve, find, put, remove: remove_, normalizePolicy, ownersOf };
}
/** The prefix a request uses to name a preset as its model: "@preset/cheapest". */
const PRESET_MODEL_PREFIX = '@preset/';
++/** The preset a key uses when its request names none: "default", set from the Exchange's key switch. */
++const DEFAULT_PRESET = 'default';
/**
* The preset a request names, and the policy that goes with it.
@@@ -206,7 +220,9 @@ const PRESET_MODEL_PREFIX = '@preset/';
* preset: "<name>" the preset beside the model
* max_price: 0.42 the hard cap on its own (USD per million tokens), whatever the preset says
*
-- * A request that names none and caps nothing has no preset: null comes back and nothing changes.
++ * A request from a developer key that names none uses that key owner's "default" preset, when they saved one:
++ * that is the strict-privacy switch on a key. A request that names none and has no default has no preset: null
++ * comes back and nothing changes.
*/
async function resolveRequest({ presets, principal, body = {} } = {}) {
const raw = body.model == null ? '' : String(body.model).trim();
@@@ -221,7 +237,14 @@ async function resolveRequest({ presets, principal, body = {} } = {}) {
}
if (!name && body.preset && typeof body.preset === 'object' && typeof body.preset.name === 'string') name = body.preset.name.trim();
const hasCap = body.max_price != null && body.max_price !== '';
-- if (!name && !inline && !hasCap) return null;
++ if (!name && !inline && !hasCap) {
++ // A developer key's default preset, when its owner saved one: the strict-privacy switch on a key.
++ if (presets && principal && principal.kind === 'app') {
++ const saved = await presets.find({ principal, name: DEFAULT_PRESET });
++ if (saved) return { name: saved.name, scope: saved.scope || 'owner', policy: { ...(saved.policy || {}) } };
++ }
++ return null;
++ }
let policy = {};
let scope = 'request';
let resolved = null;
@@@ -242,4 +265,4 @@ async function resolveRequest({ presets, principal, body = {} } = {}) {
return { name: resolved ? resolved.name : (name || 'request'), scope, policy };
}
--module.exports = { createPresets, normalizePolicy, checkName, ownersOf, resolveRequest, BUILTINS, builtinNames, isBuiltin, NAME_RE, OBJECTIVES, FLOORS, DATA_POLICIES, REGIONS, PRESET_MODEL_PREFIX };
++module.exports = { createPresets, normalizePolicy, checkName, ownersOf, resolveRequest, BUILTINS, builtinNames, isBuiltin, NAME_RE, OBJECTIVES, FLOORS, DATA_POLICIES, REGIONS, PRESET_MODEL_PREFIX, DEFAULT_PRESET };
ddiff --git a/server/exchange/privacy.js b/server/exchange/privacy.js
iindex 52d77a6..38ed5d7 100644
---- a/server/exchange/privacy.js
++++ b/server/exchange/privacy.js
@@@ -6,7 +6,8 @@
* The label is read off the offer that answered, never guessed:
*
* private the offer keeps nothing (no retention) and trains on nothing, and it is not a stranger's
-- * machine. A person's own machine is private: the prompt never leaves their hardware.
++ * machine. A person's own machine is private, and so is an offer on the host's own hardware
++ * (metadata.local): the prompt never leaves the deployment OpenVibe runs.
* community the offer runs on a community machine: that machine sees the prompt (as on AI Horde).
* anonymized everything else: an external provider that may keep what it is sent.
*
@@@ -49,6 +50,10 @@ function labelFor({ record = null, credential = null } = {}) {
if (credential && credential.provider === 'node') {
return { label: 'private', retention: 'none', training: false, region: null, trust: 'own_machine', known: true, source: 'your own machine' };
}
++ // The host's own deployment (the local models OpenVibe runs itself): nothing leaves the machine it manages.
++ if (record && record.metadata && record.metadata.local === true) {
++ return { label: 'private', retention: 'none', training: false, region: null, trust: 'own_deployment', known: true, source: 'OpenVibe\'s own deployment on its own hardware' };
++ }
const policy = dataPolicyOf(record);
if (!policy) {
return { label: 'anonymized', retention: null, training: null, region: null, trust: null, known: false, source: 'the provider declares no data policy' };
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex f31d892..0174fac 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -1221,6 +1221,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
explain: { ...explain, preset: { name: ctx.presetName || null, policy: ctx.presetPolicy } },
});
}
++ // Strict privacy: a preset that asks for a data policy no offer meets refuses the request here, with a
++ // plain message. It never falls back to an offer that keeps or trains on the prompt.
++ if (ctx.presetPolicy.data_policy && !allowed.length) {
++ const refusal = exchangeConstraints.privacyRefusal({ policy: ctx.presetPolicy, excluded });
++ throw new AiError(503, refusal.code, refusal.detail, {
++ data_policy: refusal.data_policy,
++ excluded: refusal.excluded,
++ explain: { ...explain, preset: { name: ctx.presetName || null, policy: ctx.presetPolicy } },
++ });
++ }
list = allowed;
for (const x of excluded) {
explain.candidates.push({ provider: x.provider, model: x.model, eligible: false, excluded_reason: x.reason, cost: null, latency: null });
ddiff --git a/server/runs.js b/server/runs.js
iindex fb0e62e..ec8e7dc 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -271,13 +271,28 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const prefer = preferredTarget(body.prefer);
// The routing preset the request named (server/exchange/presets.js): its policy rides the run into the
// router, which filters the offers with it and refuses a hard max_price no offer fits. The policy is
-- // normalised here, so a caller cannot smuggle a rule the presets store would never have written.
-- const preset = body.preset && typeof body.preset === 'object' && !Array.isArray(body.preset) ? body.preset : null;
++ // normalised here, so a caller cannot smuggle a rule the presets store would never have written. A preset
++ // named without a policy is resolved by name (the built-in ones, or the caller's own), so the Exchange can
++ // ask for "private" and get the one definition of it. It is named top-level (`preset`) or in the run's
++ // options (`options.preset`, the shape the Exchange's chat sends).
++ const preset = body.preset && typeof body.preset === 'object' && !Array.isArray(body.preset)
++ ? body.preset
++ : (body.options && body.options.preset && typeof body.options.preset === 'object' && !Array.isArray(body.options.preset) ? body.options.preset : null);
let presetPolicy = null;
++ let presetName = null;
if (preset) {
const name = String(preset.name || '').trim();
if (!name) throw new AiError(422, 'input.invalid', 'a preset must name itself');
-- presetPolicy = presets ? presets.normalizePolicy(preset.policy) : (preset.policy && typeof preset.policy === 'object' ? preset.policy : {});
++ presetName = name;
++ const inline = preset.policy && typeof preset.policy === 'object' && Object.keys(preset.policy).length;
++ if (inline) {
++ presetPolicy = presets ? presets.normalizePolicy(preset.policy) : preset.policy;
++ } else if (presets) {
++ const resolved = await presets.resolve({ principal, name });
++ presetPolicy = presets.normalizePolicy(resolved.policy);
++ } else {
++ presetPolicy = {};
++ }
}
if (presetPolicy && typeof presetPolicy.community === 'boolean') communityOptIn = presetPolicy.community;
if (projectProvider) {
@@@ -343,7 +358,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
...(communityOptIn ? { communityOptIn: true } : {}),
...(pinned ? { routePin: pinned } : {}),
// The preset's policy, and the name it was named by: the router filters the offers with the policy.
-- ...(preset ? { presetPolicy, presetName: String(preset.name).trim() } : {}),
++ ...(preset ? { presetPolicy, presetName } : {}),
// POST /v1/chat/completions with stream: true: the upstream's deltas, as they arrive (kept in memory only).
...(typeof onDelta === 'function' ? { onDelta } : {}),
// The offer the router picked, before the first token: the SSE disclosure's first comment (D3).
@@@ -555,6 +570,23 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
}
++ /**
++ * The privacy label of an answer, from the offer that answered: the provider's declared policy, the person's
++ * own machine, or the host's own deployment (server/exchange/privacy.js). The receipt and the run's explain
++ * both carry it, so every surface that reads the run shows the same label.
++ */
++ async function privacyLabelFor({ provider = null, explain = null } = {}) {
++ const byok = Boolean(provider && String(provider).startsWith('byo:'));
++ const credentialProvider = byok && explain && Array.isArray(explain.attempts) && explain.attempts[0] && explain.attempts[0].provider
++ ? explain.attempts[0].provider : null;
++ const record = (provider ? await registry.getProvider(provider) : null)
++ || (credentialProvider ? await registry.getProvider(credentialProvider) : null);
++ const communityAnswer = Boolean(provider && String(provider).startsWith('community:')) || Boolean(explain && explain.community);
++ return communityAnswer
++ ? privacy.labelFor({ record: { metadata: { data_policy: { trust: 'community' } } } })
++ : privacy.labelFor({ record, credential: byok ? { provider: credentialProvider } : null });
++ }
++
/**
* The receipt of a finished run (server/exchange/receipts.js): the offer, the price, the tokens, the cache,
* the latency, why the router chose it, the fallbacks it would have used, who paid and the privacy label.
@@@ -576,12 +608,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
in: cards.in.unit_price_usd, out: cards.out.unit_price_usd, cached: cards.cached.unit_price_usd,
source: cards.in.source,
} : null;
-- const record = (answerProvider ? await registry.getProvider(answerProvider) : null)
-- || (credentialProvider ? await registry.getProvider(credentialProvider) : null);
-- const communityAnswer = Boolean(answerProvider && String(answerProvider).startsWith('community:')) || Boolean(explain && explain.community);
-- const label = communityAnswer
-- ? privacy.labelFor({ record: { metadata: { data_policy: { trust: 'community' } } } })
-- : privacy.labelFor({ record, credential: byok ? { provider: credentialProvider } : null });
++ const label = await privacyLabelFor({ provider: answerProvider, explain });
const paidBy = (payer && payer.paid_by) || 'openvibe_key';
const feeRate = paidBy === 'openvibe_key' ? Number((config.usage && config.usage.feeRate) || 0) : 0;
const opts = parseJson(row.options, {});
@@@ -783,6 +810,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (r.output && r.label) {
r.output = { ...r.output, policy: r.contentPolicy || 'uncensored', label: r.label };
}
++ // The privacy label of the answer, in the run's own explain: the API routes, the Exchange's chat and every
++ // product that reads a run show the same label the receipt carries.
++ r.explain = { ...(r.explain || {}), privacy: privacy.view(await privacyLabelFor({ provider: r.provider, explain: r.explain })) };
if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
// The status, its citations and the ai.run.succeeded event commit together.
const done = await db.tx(async () => {
ddiff --git a/test/exchange-private.test.js b/test/exchange-private.test.js
nnew file mode 100644
iindex 0000000..dfaa994
---- /dev/null
++++ b/test/exchange-private.test.js
@@@ -0,0 +1,229 @@
++'use strict';
++/**
++ * Private AI (the plan of 2026-11-06, "Product lines and features", item 2):
++ *
++ * - the privacy label comes from the facts: zero retention and no training is private, a community machine is
++ * community, an undeclared policy is anonymized, and the host's own deployment (metadata.local) is private;
++ * - the catalog offers carry that label, so the Exchange shows the same one the router uses;
++ * - a preset that asks for private and finds no offer refuses with a plain message, before any provider is
++ * called, and never falls back to an offer that keeps or trains on the prompt;
++ * - a developer key's saved "default" preset applies to requests that name none (the strict-privacy switch on
++ * a key), and a service may set and read a person's presets with on_behalf_of.
++ *
++ * The upstreams replay an answer; their calls are counted, so a refusal that still called one would fail.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, request, token, suite, ALL } = require('./helpers');
++
++const t = suite('exchange-private');
++const service = token('exchange', [...ALL]);
++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const key = token('key', ['ai.app.run', 'ai.run.create'], { sub: 'app:app_01JAB2C3D4E5F6G7H8J9K0MNPQ', actorType: 'app', ns: [PRJ, `app.${PRJ}.*`], extra: { project_id: PRJ, env: 'production' } });
++const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP1';
++
++const upstreams = {};
++const calls = (name) => upstreams[name].calls.length;
++
++function answer(text) {
++ return {
++ id: 'chatcmpl-test', object: 'chat.completion', created: 1762400000, model: 'm',
++ choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
++ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
++ };
++}
++
++async function fakeUpstream(name) {
++ const state = { calls: [], url: null };
++ const server = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ state.calls.push({ url: req.url, body: raw });
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(answer(`${name} answered`)));
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ state.url = `http://127.0.0.1:${server.address().port}`;
++ state.close = () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); });
++ upstreams[name] = state;
++ return state;
++}
++
++async function makeProvider({ key: k, upstream, metadata = {}, data_policy = null, cost = [0.2, 0.6] } = {}) {
++ const made = await request(h.base, 'POST', '/api/v1/providers', {
++ tok: service,
++ body: {
++ key: k, kind: 'openai', base_url: `${upstream.url}/v1`, auth_mode: 'none',
++ capabilities: ['chat', 'complete', 'tools', 'json'], default_model: 'm',
++ provider_access: 'OWN_DEPLOYMENT', metadata: { ...(data_policy ? { data_policy } : {}), ...metadata },
++ },
++ });
++ assert.strictEqual(made.status, 201, made.text);
++ const model = await request(h.base, 'POST', '/api/v1/models', {
++ tok: service,
++ body: {
++ provider_key: k, model_key: 'm', type: 'chat', status: 'active', context_window: 32768, max_output: 4096,
++ supports: { json: true, tools: true, streaming: true, vision: false },
++ cost: { in_per_mtok: cost[0], out_per_mtok: cost[1], cached_per_mtok: cost[0] / 10, source: 'https://example.com/pricing', verified_at: '2026-11-01' },
++ metadata: { org: 'acme', model: 'm', tier: 'balanced', listed: true, synced_at: '2026-11-01T00:00:00.000Z' },
++ },
++ });
++ assert.strictEqual(model.status, 201, model.text);
++}
++
++const post = (body) => request(h.base, 'POST', '/v1/chat/completions', { tok: service, body });
++
++let h;
++
++t.test('boot: a provider that declares nothing, a private one, a community machine and the host’s own deployment', async () => {
++ h = await boot({ env: { AI_MODEL_CATALOG: '1', AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0', AI_LOCAL_LLM_URL: '', AI_FREE_PROVIDERS: 'open,private', AI_FREE_MAX_OUTPUT_PRICE_USD_PER_MTOK: '5' } });
++ for (const name of ['open', 'private', 'community']) await fakeUpstream(name);
++ await makeProvider({ key: 'open', upstream: upstreams.open });
++ await makeProvider({
++ key: 'private', upstream: upstreams.private,
++ data_policy: { trains: false, training: false, retention: 'none', trust: 'first-party' },
++ cost: [0.5, 1.5],
++ });
++ // A community machine: a person's hardware, prepaid, which sees the prompt.
++ await makeProvider({
++ key: 'community', upstream: upstreams.community, cost: [0, 0],
++ metadata: { community: true, paid: false },
++ data_policy: { trains: null, retention: null, trust: 'community' },
++ });
++});
++
++t.test('the catalog offers carry the privacy label, from the same facts the router uses', async () => {
++ const res = await request(h.base, 'GET', '/api/v1/exchange/catalog', { tok: service });
++ assert.strictEqual(res.status, 200, res.text);
++ const offers = res.body.models.flatMap((m) => m.offers);
++ const labelOf = (provider) => {
++ const o = offers.find((x) => x.provider === provider);
++ return o && o.privacy && o.privacy.label;
++ };
++ assert.strictEqual(labelOf('private'), 'private', 'zero retention and no training is private');
++ const privacy = require('../server/exchange/privacy');
++ assert.strictEqual(privacy.labelFor({ record: { metadata: { local: true } } }).label, 'private', 'the host’s own deployment is private');
++ assert.strictEqual(labelOf('community'), 'community', 'a person’s machine is community');
++ assert.strictEqual(labelOf('open'), 'anonymized', 'a provider that declares nothing is anonymized');
++});
++
++t.test('strict privacy answers from a private offer, and the community machine is never used', async () => {
++ const before = { open: calls('open'), community: calls('community') };
++ const r = await post({ model: 'openvibe/auto', preset: 'private', messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.x_openvibe.provider, 'private', 'the private offer answered');
++ assert.strictEqual(r.body.x_openvibe.privacy, 'private');
++ assert.deepStrictEqual({ open: calls('open'), community: calls('community') }, before, 'neither the community machine nor the provider that declares nothing was called');
++});
++
++t.test('strict privacy refuses with a plain message when no private offer can answer', async () => {
++ // Neither private offer runs in the EU: the strict profile has nothing to answer with, and it is refused
++ // rather than answered by an offer that keeps or trains on the prompt.
++ const before = { open: calls('open'), private: calls('private'), community: calls('community') };
++ const r = await post({
++ model: 'openvibe/auto',
++ preset: { name: 'strict-eu', policy: { data_policy: 'private', community: false, region: ['eu'] } },
++ messages: [{ role: 'user', content: 'a secret' }],
++ });
++ assert.strictEqual(r.status, 503, r.text);
++ assert.strictEqual(r.body.error.code, 'policy.privacy_unavailable');
++ assert.match(r.body.error.message, /Strict privacy is on/);
++ assert.match(r.body.error.message, /does not fall back/);
++ assert.deepStrictEqual({ open: calls('open'), private: calls('private'), community: calls('community') }, before, 'no provider was called');
++});
++
++t.test('a run names the preset by name and the router resolves the one policy', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=10000', {
++ tok: service,
++ body: { workflow: 'ai.complete', input: { messages: [{ role: 'user', content: 'hello' }] }, options: { preset: { name: 'private' } } },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ const run = r.body.run;
++ assert.strictEqual(run.status, 'succeeded', JSON.stringify(run.error));
++ assert.strictEqual(run.provenance.provider, 'private', 'the private offer answered');
++ assert.strictEqual(run.explain.privacy.label, 'private', 'the run’s own explain carries the label');
++});
++
++t.test('a developer key’s default preset is strict privacy for every request that names none', async () => {
++ // With no default, the key's free run uses the community machine (the only free offer that answers a
++ // long-enough answer; the host's short-text model is not offered above 800 tokens).
++ const before = calls('community');
++ const plain = await request(h.base, 'POST', '/v1/chat/completions', { tok: key, body: { model: 'openvibe/auto', max_tokens: 2000, messages: [{ role: 'user', content: 'hello' }] } });
++ assert.strictEqual(plain.status, 200, plain.text);
++ assert.strictEqual(plain.body.x_openvibe.provider, 'community');
++ assert.strictEqual(plain.body.x_openvibe.privacy, 'community');
++
++ const stored = await request(h.base, 'POST', '/api/v1/presets', {
++ tok: key, body: { name: 'default', policy: { data_policy: 'private', community: false } },
++ });
++ assert.strictEqual(stored.status, 201, stored.text);
++ assert.strictEqual(stored.body.preset.owner.type, 'project', 'a key writes its project’s preset');
++ // The key now asks for nothing, and its request is refused rather than answered by the community machine.
++ const r = await request(h.base, 'POST', '/v1/chat/completions', { tok: key, body: { model: 'openvibe/auto', max_tokens: 2000, messages: [{ role: 'user', content: 'hello' }] } });
++ assert.strictEqual(r.status, 503, r.text);
++ assert.strictEqual(r.body.error.code, 'policy.privacy_unavailable');
++
++ // The service token has no default of its own: its requests are not made strict by the key's choice.
++ const svc = await post({ model: 'openvibe/auto', messages: [{ role: 'user', content: 'hello' }] });
++ assert.strictEqual(svc.status, 200, svc.text);
++ assert.ok(svc.body.x_openvibe.provider, 'the service still answers');
++});
++
++t.test('a service sets and reads a person’s presets with on_behalf_of (the Exchange’s key switch)', async () => {
++ const stored = await request(h.base, 'POST', '/api/v1/presets', {
++ tok: service, body: { on_behalf_of: DANA, name: 'default', policy: { data_policy: 'private', community: false } },
++ });
++ assert.strictEqual(stored.status, 201, stored.text);
++ assert.deepStrictEqual(stored.body.preset.owner, { type: 'user', id: DANA });
++ const read = await request(h.base, 'GET', `/api/v1/presets?on_behalf_of=${DANA}`, { tok: service });
++ assert.strictEqual(read.status, 200, read.text);
++ assert.ok(read.body.presets.some((p) => p.name === 'default' && p.policy.data_policy === 'private'));
++ const removed = await request(h.base, 'DELETE', '/api/v1/presets/default', { tok: service, body: { on_behalf_of: DANA } });
++ assert.strictEqual(removed.status, 200, removed.text);
++ assert.strictEqual(removed.body.removed, true);
++});
++
++t.test('the same switch reaches a project’s keys (a project acts as itself, not as a person)', async () => {
++ const stored = await request(h.base, 'POST', '/api/v1/presets', {
++ tok: service, body: { on_behalf_of: { type: 'project', id: PRJ }, name: 'default', policy: { data_policy: 'private', community: false } },
++ });
++ assert.strictEqual(stored.status, 201, stored.text);
++ assert.deepStrictEqual(stored.body.preset.owner, { type: 'project', id: PRJ });
++ const read = await request(h.base, 'GET', `/api/v1/presets?on_behalf_of=${PRJ}`, { tok: service });
++ assert.strictEqual(read.status, 200, read.text);
++ assert.ok(read.body.presets.some((p) => p.name === 'default' && p.scope === 'owner'));
++ // A project's request that names nothing is strict from now on: the community machine is refused.
++ const refused = await request(h.base, 'POST', '/v1/chat/completions', { tok: key, body: { model: 'openvibe/auto', max_tokens: 2000, messages: [{ role: 'user', content: 'hello' }] } });
++ assert.strictEqual(refused.status, 503, refused.text);
++ assert.strictEqual(refused.body.error.code, 'policy.privacy_unavailable');
++ await request(h.base, 'DELETE', '/api/v1/presets/default', { tok: service, body: { on_behalf_of: { type: 'project', id: PRJ } } });
++});
++
++t.test('only a first-party service names whom it acts for: an app key may not touch another person or project', async () => {
++ const OTHER_PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNZZ';
++ const asPerson = await request(h.base, 'POST', '/api/v1/presets', {
++ tok: key, body: { on_behalf_of: DANA, name: 'default', policy: { data_policy: 'standard', community: true } },
++ });
++ assert.strictEqual(asPerson.status, 403, asPerson.text);
++ assert.ok(asPerson.text.includes('capability.denied'), asPerson.text);
++ const otherProject = await request(h.base, 'POST', '/api/v1/presets', {
++ tok: key, body: { on_behalf_of: { type: 'project', id: OTHER_PRJ }, name: 'default', policy: { data_policy: 'standard', community: true } },
++ });
++ assert.strictEqual(otherProject.status, 403, otherProject.text);
++ const readOther = await request(h.base, 'GET', `/api/v1/presets?on_behalf_of=${DANA}`, { tok: key });
++ assert.strictEqual(readOther.status, 403, 'nor read them');
++ const ownProject = await request(h.base, 'POST', '/api/v1/presets', {
++ tok: key, body: { on_behalf_of: { type: 'project', id: PRJ }, name: 'noted', policy: { data_policy: 'private', community: false } },
++ });
++ assert.strictEqual(ownProject.status, 201, 'naming its own project is still fine: ' + ownProject.text);
++ await request(h.base, 'DELETE', '/api/v1/presets/noted', { tok: key, body: { on_behalf_of: { type: 'project', id: PRJ } } });
++});
++
++t.test('shutdown', async () => {
++ await h.stop();
++ for (const name of Object.keys(upstreams)) await upstreams[name].close();
++});
++
++if (require.main === module) t.run();