openvibers/openvibe.ai Commits
AI: private AI — the label, the plain refusal, and a key's default preset (#326)
Changes
nnew file mode 100644@@@ -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.@@@ -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 });}));@@@ -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)) {@@@ -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 };@@@ -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 };@@@ -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' };@@@ -1221,6 +1221,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noexplain: { ...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 });@@@ -271,13 +271,28 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { nowconst 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 = { nowin: 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 = { nowif (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 () => {nnew file mode 100644@@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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.
@@ -62,19 +62,61 @@ function exchangeRouter({ db, registry, pool, env = process.env, clock = { now:
@@ -62,19 +62,61 @@ function exchangeRouter({ db, registry, pool, env = process.env, clock = { now:
const owners = presets.ownersOf(req.principal);
const owners = presets.ownersOf(req.principal);
return owners[0] || null;
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) {
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) => {
r.post('/api/v1/presets', write, wrap(async (req, res) => {
const b = body(req);
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');
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 });
const preset = await presets.put({ owner, name: b.name, policy: b.policy });
res.status(201);
res.status(201);
return { preset };
return { preset };
}));
}));
r.delete('/api/v1/presets/:name', write, wrap(async (req) => {
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');
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 });
return presets.remove({ owner, name: req.params.name });
}));
}));
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
const rates = require('../providers/rate-cards');
const rates = require('../providers/rate-cards');
const upstream = require('../providers/upstream');
const upstream = require('../providers/upstream');
const observed = require('./observed');
const observed = require('./observed');
+const privacy = require('./privacy');
const { allowsOffer } = require('../free-tier');
const { allowsOffer } = require('../free-tier');
const FAMILY_BY_PREFIX = [
const FAMILY_BY_PREFIX = [
@@ -87,7 +88,9 @@ function policyOf(provider) {
@@ -87,7 +88,9 @@ function policyOf(provider) {
return { trains, retention_days: retention, region, anonymized };
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) {
function capabilitiesOf(row) {
const s = (row && row.supports) || {};
const s = (row && row.supports) || {};
@@ -163,6 +166,9 @@ function createCatalog({ db, registry, pool, env = process.env, clock = { now: (
@@ -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_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,
price_checked_at: cards && cards.out.verified_at && cards.out.verified_at !== rates.UNKNOWN_DATE ? cards.out.verified_at : null,
data_policy: policyOf(provider),
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,
updated_at: row.updated_at || provider.updated_at || null,
};
};
if (!groups.has(slug)) {
if (!groups.has(slug)) {
@@ -99,4 +99,20 @@ function refusal({ policy, excluded = [], cheapest = null }) {
@@ -99,4 +99,20 @@ function refusal({ policy, excluded = [], cheapest = null }) {
return { code: 'ai.max_price_exceeded', detail, max_price: cap, lowest_price: lowest, excluded };
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 };
@@ -173,6 +173,18 @@ function createPresets({ db, clock = { now: () => Date.now() }, log = console }
@@ -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`);
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. */
/** 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 }) {
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');
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 }
@@ -193,11 +205,13 @@ function createPresets({ db, clock = { now: () => Date.now() }, log = console }
return { name: key, removed: r.changes > 0 };
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". */
/** The prefix a request uses to name a preset as its model: "@preset/cheapest". */
const PRESET_MODEL_PREFIX = '@preset/';
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.
* The preset a request names, and the policy that goes with it.
@@ -206,7 +220,9 @@ const PRESET_MODEL_PREFIX = '@preset/';
@@ -206,7 +220,9 @@ const PRESET_MODEL_PREFIX = '@preset/';
* preset: "<name>" the preset beside the model
* 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
* 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 = {} } = {}) {
async function resolveRequest({ presets, principal, body = {} } = {}) {
const raw = body.model == null ? '' : String(body.model).trim();
const raw = body.model == null ? '' : String(body.model).trim();
@@ -221,7 +237,14 @@ async function resolveRequest({ presets, principal, body = {} } = {}) {
@@ -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();
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 !== '';
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 policy = {};
let scope = 'request';
let scope = 'request';
let resolved = null;
let resolved = null;
@@ -242,4 +265,4 @@ async function resolveRequest({ presets, principal, body = {} } = {}) {
@@ -242,4 +265,4 @@ async function resolveRequest({ presets, principal, body = {} } = {}) {
return { name: resolved ? resolved.name : (name || 'request'), scope, policy };
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 };
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
* The label is read off the offer that answered, never guessed:
* 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
* 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).
* 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.
* anonymized everything else: an external provider that may keep what it is sent.
*
*
@@ -49,6 +50,10 @@ function labelFor({ record = null, credential = null } = {}) {
@@ -49,6 +50,10 @@ function labelFor({ record = null, credential = null } = {}) {
if (credential && credential.provider === 'node') {
if (credential && credential.provider === 'node') {
return { label: 'private', retention: 'none', training: false, region: null, trust: 'own_machine', known: true, source: 'your own machine' };
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);
const policy = dataPolicyOf(record);
if (!policy) {
if (!policy) {
return { label: 'anonymized', retention: null, training: null, region: null, trust: null, known: false, source: 'the provider declares no data policy' };
return { label: 'anonymized', retention: null, training: null, region: null, trust: null, known: false, source: 'the provider declares no data policy' };
@@ -1221,6 +1221,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1221,6 +1221,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
explain: { ...explain, preset: { name: ctx.presetName || null, policy: ctx.presetPolicy } },
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;
list = allowed;
for (const x of excluded) {
for (const x of excluded) {
explain.candidates.push({ provider: x.provider, model: x.model, eligible: false, excluded_reason: x.reason, cost: null, latency: null });
explain.candidates.push({ provider: x.provider, model: x.model, eligible: false, excluded_reason: x.reason, cost: null, latency: null });
@@ -271,13 +271,28 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -271,13 +271,28 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
const prefer = preferredTarget(body.prefer);
const prefer = preferredTarget(body.prefer);
// The routing preset the request named (server/exchange/presets.js): its policy rides the run into the
// 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
// 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 presetPolicy = null;
+ let presetName = null;
if (preset) {
if (preset) {
const name = String(preset.name || '').trim();
const name = String(preset.name || '').trim();
if (!name) throw new AiError(422, 'input.invalid', 'a preset must name itself');
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 (presetPolicy && typeof presetPolicy.community === 'boolean') communityOptIn = presetPolicy.community;
if (projectProvider) {
if (projectProvider) {
@@ -343,7 +358,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -343,7 +358,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
...(communityOptIn ? { communityOptIn: true } : {}),
...(communityOptIn ? { communityOptIn: true } : {}),
...(pinned ? { routePin: pinned } : {}),
...(pinned ? { routePin: pinned } : {}),
// The preset's policy, and the name it was named by: the router filters the offers with the policy.
// 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).
// POST /v1/chat/completions with stream: true: the upstream's deltas, as they arrive (kept in memory only).
...(typeof onDelta === 'function' ? { onDelta } : {}),
...(typeof onDelta === 'function' ? { onDelta } : {}),
// The offer the router picked, before the first token: the SSE disclosure's first comment (D3).
// 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
@@ -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 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.
* 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
@@ -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,
in: cards.in.unit_price_usd, out: cards.out.unit_price_usd, cached: cards.cached.unit_price_usd,
source: cards.in.source,
source: cards.in.source,
} : null;
} : 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 paidBy = (payer && payer.paid_by) || 'openvibe_key';
const feeRate = paidBy === 'openvibe_key' ? Number((config.usage && config.usage.feeRate) || 0) : 0;
const feeRate = paidBy === 'openvibe_key' ? Number((config.usage && config.usage.feeRate) || 0) : 0;
const opts = parseJson(row.options, {});
const opts = parseJson(row.options, {});
@@ -783,6 +810,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -783,6 +810,9 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (r.output && r.label) {
if (r.output && r.label) {
r.output = { ...r.output, policy: r.contentPolicy || 'uncensored', label: 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');
if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
// The status, its citations and the ai.run.succeeded event commit together.
// The status, its citations and the ai.run.succeeded event commit together.
const done = await db.tx(async () => {
const done = await db.tx(async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,229 @@
@@ -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();