openvibers/openvibe.ai Commits

Your AI (L36): the person's own keys, preferences and router (#224)

OP OpenVibers committed 13 days ago · 414c12ddef · 24 files, +1142 −78

* Your AI (L36): the person's own keys, preferences and router

Store a person's own provider key under the reserved owner self (AES-256-GCM, AAD self|subject|provider), for every provider the console knows, and test it on save. The person-only /api/v1/me routes refuse service tokens and on_behalf_of. A run on_behalf_of a person tries their self keys first; only_my_keys never falls back (ai.needs_key), explain gains paid_by and the attempts, and a 401/403 marks the key failing and publishes ai.credential.failed. Account export omits the key and deletion erases it.

* Person routes: a write needs audience openvibe.ai and an origin signal

A site session token (aud openvibe.network) may read the person's hints and settings but never write them. Writes accept only a user token whose aud includes openvibe.ai, or AI's own person session with a same-origin Origin/Sec-Fetch-Site; a cookie write with neither header is refused. Reads keep the old rule.

* openvibe-contracts v0.257.0 (the ai.me schemas and ai.credential.failed)

Changes

ddiff --git a/.gitleaks.toml b/.gitleaks.toml
iindex 9edc1d2..603344e 100644
---- a/.gitleaks.toml
++++ b/.gitleaks.toml
@@@ -33,3 +33,15 @@ description = "Made-up provider key in test/messages-api.test.js (the person's o
condition = "AND"
paths = ['''^test/messages-api\.test\.js$''']
regexes = ['''^sk-ant-test-person-0123456789$''']
++
++[[allowlists]]
++description = "Made-up provider keys in test/me-credentials.test.js (L36: the self vault, test-on-save, the AAD, and the 'a key is never in an answer' test; placeholders, not secrets)"
++condition = "AND"
++paths = ['''^test/me-credentials\.test\.js$''']
++regexes = ['''sk-test-dana-0123456789WXYZ''', '''sk-ant-test-0123456789ABCD''', '''sk-test-custom-0123456789''', '''sk-test-gemini-0123456789''', '''sk-test-ivan-LEAKME-0123456789''']
++
++[[allowlists]]
++description = "Made-up provider key in test/account-data.test.js (L36: the account export must never carry the key; a placeholder, not a secret)"
++condition = "AND"
++paths = ['''^test/account-data\.test\.js$''']
++regexes = ['''sk-self-ada-0123456789LEAK''']
ddiff --git a/README.md b/README.md
iindex 05ab879..9aeba8f 100644
---- a/README.md
++++ b/README.md
@@@ -26,6 +26,13 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
- people's own provider keys (`ai.credential.manage`), encrypted at rest, one per provider (Codes C3: `anthropic`,
`openai`, `deepseek`, `minimax`, `openrouter` or a `custom` endpoint), and per-streamer
attribution quotas (`ai.quota.attribution.manage`)
++- **"Your AI" (L36 Y1/Y3/Y8):** the person's own keys under the reserved owner `self` for every console provider
++ (Anthropic, OpenAI, Gemini, DeepSeek, Mistral, xAI, Groq, Cerebras, Together, Fireworks, OpenRouter, MiniMax and a
++ custom endpoint), tested on save (its models, or one token) with status, `models_count` and a redacted `last_error`;
++ their `only_my_keys`, `free_first`, price ceiling and default model per kind of work; a run `on_behalf_of` a person
++ tries their keys first, `explain.paid_by` and the attempts name who paid and why each failed, and a 401/403 marks the
++ key failing and publishes `ai.credential.failed` (`/api/v1/me/*`, `server/me.js`; only the person's own token or
++ AI-site session, never a service token or `on_behalf_of`)
- a person's own computer as their provider (credential provider `node`, contracts 0.220.0): a model on their own
machine (Ollama, LM Studio, llama.cpp through the OpenVibe Node), reached through OpenVibe.Bot with `bot.machine.ai`
for that person only, free and with no key (`server/machines.js`; Bot on this host by default, `OV_BOT_INTERNAL_URL`);
@@@ -58,7 +65,8 @@ Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`;
[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,
`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject[/:provider]`),
`ai.quota.attribution.manage` (per-streamer quotas), `ai.memory.read` and `ai.memory.write` (Memory M2a,
--`/api/v1/memory/*`) and `ai.app.run` (developer apps, ADR-014; public,
++`/api/v1/memory/*`), `ai.me.read` and `ai.me.manage` ("Your AI", `/api/v1/me/*`: a person's own keys and
++routing preferences; only the person's own token or AI-site session) and `ai.app.run` (developer apps, ADR-014; public,
registered since openvibe-contracts 0.109.0). A service token's `ns`
claim limits which workflow namespaces it may run; an app token is fixed to its project's.
@@@ -114,6 +122,7 @@ idempotent replay):
| `GET /api/v1/actions?subject=usr_…` — one person's Action Center items (`common.action-list-result@1`, runs whose `on_behalf_of` is that person) | `ai.actions.read` (first-party service) |
| `POST /api/v1/memory/items:batch`, `GET/PATCH/DELETE /api/v1/memory/items[/:id]`, `DELETE /api/v1/memory/items?source=`, `GET /api/v1/memory/profile` | `ai.memory.read` / `ai.memory.write` (the person's own token, or a service acting `on_behalf_of` them) |
| `GET /api/v1/memory/search?q=&scopes=` — active items only, at most 8, in the grant's scopes, audited | `ai.memory.read` plus `on_behalf_of` |
++| `GET/PUT/DELETE /api/v1/me/credentials[/:provider]`, `POST /api/v1/me/credentials/:provider/test`, `GET/PUT /api/v1/me/ai/preferences` | `ai.me.read` / `ai.me.manage` — the person's own token or AI-site session only; a service token or `on_behalf_of` is refused. A site session token (audience `openvibe.network`) may read these hints and settings but never write them: a write needs a token whose `aud` includes `openvibe.ai`, or AI's own same-origin session |
| `GET /api/v1/workflows\|templates\|routes[/:key]` | `ai.run.create`, `ai.workflow.manage` or `ai.usage.read` |
| `GET /api/v1/routes?operation=generate` | the same read: every route's `offers` (`{provider, model}`), plus the operation's deduplicated `offers` |
| `POST /api/v1/{templates,workflows,routes}/:key/versions`, `…/versions/:v/status` | `ai.workflow.manage` |
ddiff --git a/STATUS.json b/STATUS.json
iindex adbecb1..0dc2980 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"runtime": "node 22, express, postgresql",
"port": 4700,
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
-- "contracts": "openvibe-contracts v0.252.0",
++ "contracts": "openvibe-contracts v0.252.0 (the ai.me.* schemas arrive in v0.257.0; the person routes also work without them until this pin moves)",
"sdk": "openvibe-sdk v0.75.0",
"shared": "openvibe-shared v4.62.0",
"capabilities": [
@@@ -17,7 +17,12 @@
"ai.provider.manage",
"ai.usage.read",
"ai.credential.manage",
-- "ai.quota.attribution.manage"
++ "ai.quota.attribution.manage",
++ "ai.actions.read",
++ "ai.memory.read",
++ "ai.memory.write",
++ "ai.me.read",
++ "ai.me.manage"
],
"capabilitiesRegistered": true,
"providers": [
@@@ -39,6 +44,7 @@
"scoped cache",
"citations or explicit gaps on every run",
"people's own provider keys, encrypted at rest",
++ "Your AI (L36): the person's own keys under owner self for every console provider, tested on save (models, or one token) with status/last_error/models_count; only_my_keys, free_first and a price ceiling per person; a run on_behalf_of a person tries their keys first and explain names paid_by and every attempt; a 401/403 marks the key failing and publishes ai.credential.failed",
"media.analyze: local-first recording analysis with windowed speech-to-text",
"ai.run.* events through the SDK outbox",
"ai.preferences and ai.usage_summary user modules",
ddiff --git a/migrations/0019_credential_health.sql b/migrations/0019_credential_health.sql
nnew file mode 100644
iindex 0000000..8544c18
---- /dev/null
++++ b/migrations/0019_credential_health.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++-- L36 Y1/Y8: the health of a person's own provider key, and every provider the console knows.
++-- Expand only: the new columns have defaults, so rows and releases before this migration keep working; the
++-- provider CHECK is widened to a superset, so an older release's inserts still pass.
++-- status ok | failing | untested: what the last save-time or run-time check found
++-- checked_at when that check ran (ISO 8601 UTC)
++-- last_error the redacted reason it failed (never the key)
++-- models_count how many models the provider listed when it was last tested
++ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS status text COLLATE "C" NOT NULL DEFAULT 'untested';
++ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS checked_at text COLLATE "C";
++ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS last_error text COLLATE "C";
++ALTER TABLE subject_credentials ADD COLUMN IF NOT EXISTS models_count integer NOT NULL DEFAULT 0;
++ALTER TABLE subject_credentials DROP CONSTRAINT IF EXISTS subject_credentials_provider_check;
++ALTER TABLE subject_credentials ADD CONSTRAINT subject_credentials_provider_check
++ CHECK (provider IN ('openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom',
++ 'gemini', 'mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks'));
ddiff --git a/migrations/0020_person_preferences.sql b/migrations/0020_person_preferences.sql
nnew file mode 100644
iindex 0000000..9c4846c
---- /dev/null
++++ b/migrations/0020_person_preferences.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- L36 Y3: one person's ai.me-preferences (only_my_keys, free_first, price_ceiling_usd_per_mtok and a default
++-- model or `auto` per kind of work), stored per person. Expand only: a service without this table keeps running
++-- (its routes simply answer the defaults), and the row holds JSON so more preferences add without a migration.
++CREATE TABLE IF NOT EXISTS ai_person_preferences (
++ subject text COLLATE "C" PRIMARY KEY,
++ preferences text COLLATE "C" NOT NULL DEFAULT '{}',
++ created_at text COLLATE "C" NOT NULL,
++ updated_at text COLLATE "C" NOT NULL
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex b117dc3..e3c4ee9 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"pg": "^8.23.0"
@@@ -1540,9 +1540,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.252.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",
-- "integrity": "sha512-+b51PEN0xTiPlHtkrBpLq0cGP4bCZD2Irg0lAqm7CxA6QdIe2NFSq/kGsF6MmYXlGxIrOoe5Nw0u72xUDjgQSg==",
++ "version": "0.257.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0",
++ "integrity": "sha512-fzyXLBKCqrOjUvlUB6UKxZQgrwOMhXaQK8lyxmmpkIujcd8AaIRW0DVTvYGIRJGCf/1Z3RiYS5gK1XLG8rkXFA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 7de5b89..c33c0e4 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.252.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.257.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.75.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"pg": "^8.23.0"
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 3a51ca2..5afdc19 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -3,8 +3,10 @@
* Account export and deletion → AI (ADR-033; openvibe-sdk/account-data). What AI holds about a person:
*
* - their console and person sessions (Console sign-in and the AI chat), erased;
-- * - the provider credentials they brought (server/credentials.js): the encrypted key, its hint and the budget, erased
-- * with them (the key itself is never exported, only its hint);
++ * - the provider credentials they brought (server/credentials.js), including the keys they hold themselves (owner
++ * `self`, L36): the encrypted key, its hint, status and the budget, erased with them (the key itself is never
++ * exported, only its hint and health);
++ * - their ai.me-preferences (server/person-preferences.js): how their runs route. Their own settings, so they go;
* - their runs: the workflows AI ran for them (runs.on_behalf_of is their SubjectRef) and the requests, citations and
* private cache entries under them. Those are the person's own content, so they go with the account (ADR-033);
* - the free-allowance usage the allowance ledger counts (server/free-allowance.js) and the usage counters: kept,
@@@ -27,7 +29,10 @@ const TABLES = [
{ table: 'console_sessions', subject: 'subject', file: 'console-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
{ table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
-- columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'created_at', 'updated_at', 'last_used_at'] },
++ columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'status', 'checked_at', 'last_error', 'models_count', 'created_at', 'updated_at', 'last_used_at'] },
++ // L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own
++ // settings, so they go with the account; the row holds no key.
++ { table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },
// Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is
// never exported sealed (it would be unreadable to the person) and the generic table export stays out of it.
{ table: 'memory_items', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
ddiff --git a/server/app.js b/server/app.js
iindex c4dee34..5675953 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -21,7 +21,7 @@ const ovServe = require('openvibe-shared/serve');
const home = require('./home');
const pkg = require('../package.json');
--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {
++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
@@@ -117,6 +117,11 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
const personSessions = createPersonSessions({ db, config, clock });
++ // The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only
++ // the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
++ if (credentials && preferences) {
++ app.use(require('./me').meRouter({ credentials, preferences, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
++ }
app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));
app.use(runPageRouter({ runs, personSessions, log }));
ddiff --git a/server/config.js b/server/config.js
iindex 2b28e64..4810377 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -58,6 +58,9 @@ function load(env = process.env) {
issuer: trimUrl(env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network'),
// A person's own provider keys (server/credentials.js): AES-256-GCM, 64 hex characters; unset = credentials off.
credentialsKey: String(env.AI_CREDENTIALS_KEY || '').trim(),
++ // Test-on-save for a person's own key (L36 Y1): a model list, else one token, within this budget. Ten seconds in
++ // production; a test may shorten it.
++ credentialTestTimeoutMs: Math.max(50, Math.min(60000, int(env.AI_CREDENTIAL_TEST_TIMEOUT_MS, 10000))),
// The person's memory (server/memory/): AES-256-GCM with a key of its own, so it can rotate apart from credentials.
memoryKey: String(env.AI_MEMORY_KEY || '').trim(),
// The store's own maintenance (server/memory/jobs.js): owner by owner, with a per-run cap. Off in test mode
ddiff --git a/server/console/presets.js b/server/console/presets.js
iindex c37d2a4..dc1d420 100644
---- a/server/console/presets.js
++++ b/server/console/presets.js
@@@ -28,4 +28,25 @@ const TEXT_CAPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enri
const byId = (id) => PRESETS.find((p) => p.id === id) || null;
--module.exports = { PRESETS, TEXT_CAPS, byId };
++/**
++ * The providers a person may store their own key for (L36 Y1). One table: each credential provider is a preset
++ * above, so its base URL and wire format (OpenAI-compatible or Anthropic Messages) are the console's, never a
++ * second list. DeepSeek and MiniMax keep their Anthropic-format endpoints (their keys speak Messages there, which is
++ * what a person's stored model expects); `custom` is the preset with an empty base URL, and a person names it.
++ * `node` is not here: it is the person's own machine, not a key (server/credentials.js keeps its old route).
++ */
++const CREDENTIAL_PRESET = {
++ anthropic: 'anthropic', openai: 'openai', gemini: 'gemini', deepseek: 'deepseek-anthropic',
++ mistral: 'mistral', xai: 'xai', groq: 'groq', cerebras: 'cerebras', together: 'together',
++ fireworks: 'fireworks', openrouter: 'openrouter', minimax: 'minimax-anthropic', custom: 'custom',
++};
++const CREDENTIAL_PROVIDERS = Object.keys(CREDENTIAL_PRESET);
++
++/** The console preset behind a credential provider: { id, kind, base_url, model, keys } or null. */
++function credentialProvider(id) {
++ const preset = byId(CREDENTIAL_PRESET[id]);
++ return preset ? { id, kind: preset.kind, base_url: preset.base_url, model: preset.model, keys: preset.keys } : null;
++}
++
++module.exports = { PRESETS, TEXT_CAPS, byId, CREDENTIAL_PROVIDERS, CREDENTIAL_PRESET, credentialProvider };
++
ddiff --git a/server/credentials.js b/server/credentials.js
iindex 8644a53..f6bf302 100644
---- a/server/credentials.js
++++ b/server/credentials.js
@@@ -1,61 +1,174 @@
'use strict';
/**
-- * A person's own provider key (roadmap WS-O task 2; Contracts ai.credential.manage, ai.credential-put@2 and
-- * ai.credential@2 since Codes C3). The service that holds the person's consent (Live, for a streamer's AI viewers;
-- * Codes, for the people it builds for) stores the key here; it never leaves OpenVibe.AI again. Runs that name the
-- * credential (ai.run-request credential { subject, provider? }) call that provider with that key only
-- * (server/providers/index.js executeWithCredential), never a shared key.
++ * A person's own provider key (roadmap WS-O task 2; L36 Y1/Y8; Contracts ai.credential.manage,
++ * ai.credential-put@2 and ai.credential@2 since Codes C3, and ai.me.* since L36).
*
-- * PUT /api/v1/credentials/:subject store or replace the DEFAULT provider's credential (the v1 route)
-- * PUT /api/v1/credentials/:subject/:provider store or replace that provider's credential
-- * GET /api/v1/credentials/:subject[/:provider] read it back without the key: a four-character hint, today's spend
++ * A service that holds the person's consent (Live for a streamer's AI viewers, Codes for the people it builds for)
++ * stores the key here and never sees it again. A run that names the credential (ai.run-request credential
++ * { subject, provider? }) calls that provider with that key only (server/providers/index.js
++ * executeWithCredential), never a shared key.
++ *
++ * PUT /api/v1/credentials/:subject store the DEFAULT provider's credential (the v1 route)
++ * PUT /api/v1/credentials/:subject/:provider store that provider's credential
++ * GET /api/v1/credentials/:subject[/:provider] read it back without the key
* DELETE /api/v1/credentials/:subject[/:provider]
*
-- * A person may keep one credential per provider (anthropic, openai, deepseek, minimax, openrouter, custom, node), each
-- * with its own daily budget; the provider-less routes read and write the default one, which is the row a pre-C3 caller
-- * stored (migrations/0014).
++ * The person's own routes (L36 Y1) store keys the person holds themselves under the reserved owner `self`:
++ *
++ * GET /api/v1/me/credentials
++ * PUT /api/v1/me/credentials/:provider
++ * DELETE /api/v1/me/credentials/:provider
++ * POST /api/v1/me/credentials/:provider/test
++ *
++ * A person may keep one credential per provider (the console presets: anthropic, openai, gemini, deepseek, mistral,
++ * xai, groq, cerebras, together, fireworks, openrouter, minimax and a custom endpoint; plus node for the old routes),
++ * each with its own daily budget. The provider-less routes read and write the default one, which is the row a pre-C3
++ * caller stored (migrations/0014).
*
* At rest the key is AES-256-GCM encrypted with AI_CREDENTIALS_KEY (64 hex characters) and bound to its owner, subject
-- * AND provider (the additional data), so a row copied to another owner, subject or provider does not decrypt. Rows
-- * written before the provider dimension were sealed with owner|subject only (`v1:`); they are read with that AAD and
-- * rewritten as `v2:` (owner|subject|provider) on first use. Without the key configured the routes answer 503 and
-- * credentialed runs are refused. Spend is what the credential's requests cost today (provider_key byo:<owner>:<subject>
-- * for a row stored before C3 or through the provider-less route, byo:<owner>:<subject>:<provider> for the rest),
-- * checked against the optional budget_usd_per_day.
++ * AND provider (the additional data), so a row copied to another owner, subject or provider does not decrypt. The
++ * person's own rows use the reserved owner `self` and the same scheme (`self|subject|provider`). Rows written before
++ * the provider dimension were sealed with owner|subject only (`v1:`); they are read with that AAD and rewritten as
++ * `v2:` (owner|subject|provider) on first use. Without the key configured the routes answer 503 and credentialed runs
++ * are refused. Spend is what the credential's requests cost today (provider_key byo:<owner>:<subject> for a row
++ * stored before C3 or through the provider-less route, byo:<owner>:<subject>:<provider> for the rest), checked against
++ * the optional budget_usd_per_day. A person's own key is never billed: its runs carry no network budget.
++ *
++ * Test on save (L36 Y1): list the provider's models and count them; if it lists none, one token to its cheapest model;
++ * store status, checked_at, models_count and a redacted last_error. The key is never returned, never logged and never
++ * put in an error, even when the provider echoes it.
*
-- * Provider node (contracts 0.220.0) is the person's own machine instead: `machine` names it, there is no key (nothing is
-- * sealed, no hint), and a run reaches it only through OpenVibe.Bot (server/machines.js), for that person only.
++ * Provider node (contracts 0.220.0) is the person's own machine instead: `machine` names it, there is no key (nothing
++ * is sealed, no hint), and a run reaches it only through OpenVibe.Bot (server/machines.js), for that person only.
*/
const crypto = require('crypto');
const express = require('express');
--const { validate } = require('openvibe-contracts');
++const contracts = require('openvibe-contracts');
++const { validate } = contracts;
++
++/**
++ * ai.me-* are added by openvibe-contracts 0.257.0. A service pinning an older release (until the owner tags it) has no
++ * schema for them, so the route validates the essential shape itself; with 0.257.0 or later the contract is the rule.
++ */
++function validateMe(id, body) {
++ try { return contracts.schema(id) ? contracts.validate(id, body) : null; } catch { return null; }
++}
++function manualMePut(provider, body) {
++ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
++ if (body.provider !== provider) return `body.provider is ${body.provider}, the route names ${provider}`;
++ if (body.key != null && (typeof body.key !== 'string' || body.key.length < 8 || body.key.length > 400)) return 'key must be 8-400 characters';
++ if (body.base_url != null && !/^https:\/\/[^\s/?#@]+(\/[^\s?#]*)?$/.test(String(body.base_url))) return 'base_url must be an https URL without credentials, query or fragment';
++ if (provider === 'custom' && !body.base_url) return 'a custom provider needs base_url';
++ if (body.models != null && (typeof body.models !== 'object' || Array.isArray(body.models))) return 'models must be an object';
++ if (body.budget_usd_per_day != null && !(Number(body.budget_usd_per_day) >= 0 && Number(body.budget_usd_per_day) <= 1000)) return 'budget_usd_per_day must be 0-1000';
++ return null;
++}
const { AiError } = require('./util');
++const { CREDENTIAL_PROVIDERS, credentialProvider } = require('./console/presets');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
--const PROVIDERS = ['openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom'];
--/** Where each provider answers when the person stored no base_url. DeepSeek and MiniMax speak the Anthropic format at
-- * their own /anthropic path; a custom provider always names its endpoint. */
--const PROVIDER_BASE_URL = {
-- anthropic: 'https://api.anthropic.com/v1', openai: 'https://api.openai.com/v1', deepseek: 'https://api.deepseek.com/anthropic',
-- minimax: 'https://api.minimax.io/anthropic', openrouter: 'https://openrouter.ai/api/v1',
++/** The reserved owner of a key the person holds themselves (L36 Y1). */
++const SELF_OWNER = 'self';
++const LEGACY_PROVIDERS = ['openai', 'anthropic', 'node', 'deepseek', 'minimax', 'openrouter', 'custom'];
++const PROVIDERS = [...new Set([...LEGACY_PROVIDERS, ...CREDENTIAL_PROVIDERS])];
++/** Where each provider answers when the person stored no base_url: the ONE table in server/console/presets.js. */
++const PROVIDER_BASE_URL = Object.fromEntries(CREDENTIAL_PROVIDERS
++ .map((id) => [id, (credentialProvider(id) || {}).base_url || null])
++ .filter(([, url]) => url));
++const PROVIDER_KIND = Object.fromEntries(CREDENTIAL_PROVIDERS.map((id) => [id, (credentialProvider(id) || {}).kind || 'openai']));
++/** The model a provider's test uses when it lists no models (and a saved key names none). */
++const DEFAULT_MODEL = {
++ anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', gemini: 'gemini-2.5-flash', deepseek: 'deepseek-chat',
++ mistral: 'mistral-small-latest', xai: 'grok-4-fast', groq: 'openai/gpt-oss-20b', cerebras: 'llama-3.3-70b',
++ together: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', fireworks: 'accounts/fireworks/models/llama-v3p3-70b-instruct',
++ openrouter: 'openrouter/auto', minimax: 'MiniMax-M2', custom: null,
};
++/** Providers that report a balance somewhere public (L36 Y1 "its balance where the provider reports one"). */
++const BALANCE_PROVIDERS = new Set(['openrouter', 'deepseek']);
function providerKeyFor(owner, subject, provider = null) { return provider ? `byo:${owner}:${subject}:${provider}` : `byo:${owner}:${subject}`; }
/** The AAD a key is sealed with: moving the row to another owner, subject or provider must not decrypt it. */
function aadFor(owner, subject, provider) { return `${owner}|${subject}|${provider}`; }
function legacyAadFor(owner, subject) { return `${owner}|${subject}`; }
++const hintOf = (key) => `…${String(key).slice(-4)}`;
++
++/** Replace the key (and anything that looks like one) before an upstream error is stored, logged or answered. */
++function scrub(text, key) {
++ let t = String(text == null ? '' : text);
++ if (key) t = t.split(String(key)).join('[redacted]');
++ return t.replace(/\b(sk|key|token)[-_][A-Za-z0-9_-]{8,}\b/gi, '[redacted]').slice(0, 500);
++}
--function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
++/** A provider's auth headers, the same way its adapter sends them (server/providers/anthropic.js). */
++function authHeaders(provider, base, key) {
++ if (PROVIDER_KIND[provider] === 'anthropic') {
++ const headers = { 'x-api-key': key, 'anthropic-version': '2023-06-01' };
++ if (!/anthropic\.com/i.test(String(base || ''))) headers.Authorization = `Bearer ${key}`;
++ return headers;
++ }
++ return { Authorization: `Bearer ${key}` };
++}
++
++function withDeadline(promise, ms, message) {
++ let timer;
++ const timeout = new Promise((_, reject) => {
++ timer = setTimeout(() => reject(Object.assign(new Error(message || `no response within ${Math.round(ms / 1000)}s`), { code: 'provider.timeout' })), ms);
++ if (timer.unref) timer.unref();
++ });
++ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
++}
++
++async function bodyText(res) {
++ try { return await res.text(); } catch { return ''; }
++}
++
++function modelIds(json) {
++ const list = Array.isArray(json) ? json
++ : json && Array.isArray(json.data) ? json.data
++ : json && Array.isArray(json.models) ? json.models : [];
++ const out = [];
++ for (const m of list) {
++ const id = typeof m === 'string' ? m : m && (m.id || m.name);
++ if (typeof id === 'string' && id && !out.includes(id)) out.push(id);
++ }
++ return out;
++}
++
++/** { amount, currency } when the provider reports one, else null. A balance failure never fails the key test. */
++async function balanceOf(provider, base, key, fetchImpl, timeoutMs) {
++ if (!BALANCE_PROVIDERS.has(provider)) return null;
++ try {
++ const url = provider === 'openrouter'
++ ? `${String(base).replace(/\/+$/, '')}/credits`
++ : `${new URL(base).origin}/user/balance`;
++ const res = await withDeadline(fetchImpl(url, { method: 'GET', headers: { Accept: 'application/json', ...authHeaders(provider, base, key) }, signal: AbortSignal.timeout(timeoutMs) }), timeoutMs);
++ if (!res.ok) return null;
++ const j = await res.json().catch(() => null);
++ if (!j) return null;
++ if (provider === 'openrouter') {
++ const d = j.data || j;
++ const amount = Number(d.total_credits) - Number(d.total_usage);
++ return Number.isFinite(amount) ? { amount: Math.round(amount * 1e6) / 1e6, currency: 'USD' } : null;
++ }
++ const info = Array.isArray(j.balance_infos) ? j.balance_infos[0] : null;
++ const amount = Number(info && info.total_balance);
++ return Number.isFinite(amount) ? { amount, currency: String((info && info.currency) || 'USD').toUpperCase().slice(0, 3) } : null;
++ } catch { return null; }
++}
++
++function createCredentials({ db, config, clock = { now: () => Date.now() }, fetchImpl = null }) {
// subject_credentials is in migrations/0001_initial.sql (ADR-035); 0011 added the node machine, 0014 the provider
-- // dimension (is_default, spend_key, one row per provider).
++ // dimension (is_default, spend_key, one row per provider), 0019 the health of a key.
const iso = (ms) => new Date(ms).toISOString();
++ const doFetch = fetchImpl || require('./providers/guarded-fetch').guardedFetch;
++ const testTimeout = () => Number(config.credentialTestTimeoutMs) || 10000;
function secretKey() {
const hex = String(config.credentialsKey || '');
if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'credentials.unavailable', 'person credentials are not configured on this service (AI_CREDENTIALS_KEY)');
return Buffer.from(hex, 'hex');
}
-- /** v2: bound to owner, subject and provider (Codes C3). */
++ /** v2: bound to owner, subject and provider (Codes C3; `self` for the person's own rows, L36). */
function seal(plain, aad) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', secretKey(), iv);
@@@ -100,6 +213,17 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
created_at: row.created_at, updated_at: row.updated_at, last_used_at: row.last_used_at || null,
};
}
++ /** The person's own view (ai.me-credential@1): no owner/subject/spend, no key, a four-character hint. */
++ function selfView(row) {
++ return {
++ provider: row.provider, hint: String(row.key_hint || '').slice(-4) || '????',
++ base_url: row.base_url || null,
++ budget_usd_per_day: row.budget_usd_day == null ? null : row.budget_usd_day,
++ status: row.status || 'untested', checked_at: row.checked_at || null, last_error: row.last_error || null,
++ models_count: Number(row.models_count) || 0,
++ created_at: row.created_at, updated_at: row.updated_at,
++ };
++ }
const getRow = async (owner, subject, provider) => provider
? await db.prepare('SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(owner, subject, provider)
: await db.prepare('SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND is_default').get(owner, subject);
@@@ -142,7 +266,7 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner, subject, provider) DO UPDATE SET base_url = excluded.base_url, key_enc = excluded.key_enc,
key_hint = excluded.key_hint, models = excluded.models, budget_usd_day = excluded.budget_usd_day, is_default = subject_credentials.is_default OR excluded.is_default, updated_at = excluded.updated_at`)
-- .run(owner, subject, body.provider, body.base_url || null, seal(key, aadFor(owner, subject, body.provider)), `…${key.slice(-4)}`, JSON.stringify(body.models || {}),
++ .run(owner, subject, body.provider, body.base_url || null, seal(key, aadFor(owner, subject, body.provider)), hintOf(key), JSON.stringify(body.models || {}),
body.budget_usd_per_day ? Number(body.budget_usd_per_day) : null, makeDefault,
(prev && prev.spend_key) || (makeDefault ? providerKeyFor(owner, subject) : providerKeyFor(owner, subject, body.provider)), prev ? prev.created_at : at, at);
});
@@@ -194,18 +318,182 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
}
}
-- /** The decrypted credential for one execution: { providerKey, provider, base_url, apiKey|machine, models }. */
++ /** The decrypted credential for one execution: { providerKey, owner, provider, base_url, apiKey|machine, models, hint }. */
async function forRun(owner, subject, provider = null) {
if (provider !== null && !PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', 'no credential stored for that provider');
const r = await getRow(owner, subject, provider);
if (!r) throw new AiError(404, 'credential.not_found', provider ? `the ${provider} credential was deleted` : 'the credential was deleted');
await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), owner, subject, r.provider);
const providerKey = r.spend_key || providerKeyFor(owner, subject);
-- if (r.provider === 'node') return { providerKey, provider: 'node', subject, machine: r.machine, models: JSON.parse(r.models || '{}') };
-- return { providerKey, provider: r.provider, subject, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null, apiKey: await openRow(r), models: JSON.parse(r.models || '{}') };
++ const hint = String(r.key_hint || '').slice(-4);
++ if (r.provider === 'node') return { providerKey, owner: r.owner, subject, provider: 'node', machine: r.machine, models: JSON.parse(r.models || '{}'), hint };
++ return { providerKey, owner: r.owner, subject, provider: r.provider, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null, apiKey: await openRow(r), models: JSON.parse(r.models || '{}'), hint };
++ }
++
++ // ── The person's own keys (owner `self`, L36 Y1) ───────────────────────
++
++ /** Every self row for one person, newest first. */
++ async function selfRows(subject) {
++ if (!SUBJECT_RE.test(String(subject))) return [];
++ return await db.prepare("SELECT * FROM subject_credentials WHERE owner = ? AND subject = ? AND provider <> 'node' ORDER BY provider").all(SELF_OWNER, subject);
++ }
++ async function listSelf(subject) {
++ return (await selfRows(subject)).map(selfView);
++ }
++ async function hasSelf(subject) {
++ return (await selfRows(subject)).length > 0;
++ }
++
++ /** A person's own credential for one provider (no key returned to the caller; server-side use only). */
++ async function selfForRun(subject, provider) {
++ if (!CREDENTIAL_PROVIDERS.includes(provider)) return null;
++ const r = await getRow(SELF_OWNER, subject, provider);
++ if (!r) return null;
++ await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), SELF_OWNER, subject, provider);
++ return {
++ providerKey: r.spend_key || providerKeyFor(SELF_OWNER, subject, provider), owner: SELF_OWNER, subject,
++ provider: r.provider, base_url: r.base_url || PROVIDER_BASE_URL[r.provider] || null,
++ apiKey: await openRow(r), models: JSON.parse(r.models || '{}'), hint: String(r.key_hint || '').slice(-4), status: r.status,
++ };
++ }
++ /** Every self credential a run may try first (L36 Y6). A row whose key cannot be opened is skipped, never fatal. */
++ async function selfForAllRuns(subject) {
++ const out = [];
++ for (const r of await selfRows(subject)) {
++ try {
++ const cred = await selfForRun(subject, r.provider);
++ if (cred) out.push(cred);
++ } catch { /* a broken row must not take the person's other keys with it */ }
++ }
++ return out;
++ }
++
++ /** The person's own PUT: store or replace one provider's key, then test it on save. */
++ async function putSelf(subject, provider, body) {
++ if (!SUBJECT_RE.test(String(subject))) throw new AiError(400, 'input.invalid', 'subject must be a usr_ subject');
++ if (!CREDENTIAL_PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', `no credential is stored for ${provider}`);
++ const v = validateMe('ai.me-credential-put@1', body);
++ const manual = v ? null : manualMePut(provider, body);
++ if ((v && !v.valid) || manual) throw new AiError(422, 'input.invalid', 'body does not match ai.me-credential-put@1', { errors: v ? v.errors : [{ path: '', message: manual }] });
++ if (body.provider !== provider) throw new AiError(422, 'input.invalid', `body.provider is ${body.provider}, the route names ${provider}`);
++ const prev = await getRow(SELF_OWNER, subject, provider);
++ const at = iso(clock.now());
++ const sameEndpoint = prev && (prev.base_url || null) === (body.base_url || null);
++ if (body.key == null && !sameEndpoint) throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the endpoint' : 'key is required the first time');
++ const key = body.key != null ? String(body.key) : await openRow(prev);
++ await db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, key_enc, key_hint, models, budget_usd_day, is_default, spend_key, status, checked_at, last_error, models_count, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, false, ?, 'untested', NULL, NULL, 0, ?, ?)
++ ON CONFLICT(owner, subject, provider) DO UPDATE SET base_url = excluded.base_url, key_enc = excluded.key_enc,
++ key_hint = excluded.key_hint, models = excluded.models, budget_usd_day = excluded.budget_usd_day,
++ status = 'untested', checked_at = NULL, last_error = NULL, models_count = 0, updated_at = excluded.updated_at`)
++ .run(SELF_OWNER, subject, provider, body.base_url || null, seal(key, aadFor(SELF_OWNER, subject, provider)), hintOf(key),
++ JSON.stringify(body.models || {}), body.budget_usd_per_day ? Number(body.budget_usd_per_day) : null,
++ providerKeyFor(SELF_OWNER, subject, provider), prev ? prev.created_at : at, at);
++ await testSelf(subject, provider);
++ return selfView(await getRow(SELF_OWNER, subject, provider));
}
-- return { put, get, remove, admit, forRun, spentToday, providerKeyFor };
++ async function removeSelf(subject, provider) {
++ if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;
++ return (await db.prepare('DELETE FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').run(SELF_OWNER, subject, provider)).changes > 0;
++ }
++
++ /** Mark a key failing (a provider 401/403 in a run, L36 Y8). Never stores the key. */
++ async function markFailed(owner, subject, provider, message) {
++ if (!owner || !SUBJECT_RE.test(String(subject)) || !PROVIDERS.includes(provider)) return false;
++ const at = iso(clock.now());
++ return (await db.prepare('UPDATE subject_credentials SET status = ?, checked_at = ?, last_error = ? WHERE owner = ? AND subject = ? AND provider = ?')
++ .run('failing', at, String(message || 'the provider refused the key').slice(0, 500), owner, subject, provider)).changes > 0;
++ }
++
++ /**
++ * Test one stored key (L36 Y1): the model list, else one token to its cheapest model, within testTimeout().
++ * Stores status, checked_at, models_count and a redacted last_error, and returns the ai.me-credential-test@1 body.
++ * For a custom (or overridden) endpoint the production fetch is the guarded fetcher, so the egress rules hold.
++ */
++ async function testSelf(subject, provider) {
++ if (!CREDENTIAL_PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', `no credential is stored for ${provider}`);
++ const row = await getRow(SELF_OWNER, subject, provider);
++ if (!row) throw new AiError(404, 'credential.not_found', `no ${provider} credential stored`);
++ const key = await openRow(row);
++ const spec = credentialProvider(provider) || {};
++ const base = String(row.base_url || PROVIDER_BASE_URL[provider] || '').replace(/\/+$/, '');
++ const timeoutMs = testTimeout();
++ const started = Date.now();
++ let models = [];
++ let status = 'ok';
++ let error = null;
++ let balance = null;
++ const headers = { Accept: 'application/json', 'Content-Type': 'application/json', ...authHeaders(provider, base, key) };
++ if (!base) {
++ status = 'failing';
++ error = { code: 'credential.endpoint_required', message: 'this provider needs a base URL' };
++ } else {
++ try {
++ const res = await withDeadline(doFetch(`${base}/models`, { method: 'GET', headers, signal: AbortSignal.timeout(timeoutMs) }), timeoutMs);
++ if (res.ok) {
++ models = modelIds(await res.json().catch(() => null));
++ } else if (res.status === 401 || res.status === 403) {
++ throw Object.assign(new Error(`the provider refused the key (${res.status})`), { status: res.status, code: 'provider.auth' });
++ } else {
++ // A provider with no /models endpoint is not a failed key: try the one-token call below.
++ models = [];
++ }
++ } catch (err) {
++ if (err && (err.status === 401 || err.status === 403)) {
++ status = 'failing';
++ error = { code: 'provider.auth', message: scrub(err.message, key) };
++ } else if (err && err.code === 'provider.timeout') {
++ status = 'failing';
++ error = { code: 'provider.timeout', message: scrub(err.message, key) };
++ } else if (err && err.code === 'EADDRNOTPUBLIC') {
++ // The guarded fetcher's egress rule (https, public address, no credentials in the URL) refused the
++ // endpoint: that is a bad endpoint, not a provider without a model list.
++ status = 'failing';
++ error = { code: 'credential.endpoint_refused', message: scrub(err.message, key) };
++ } else {
++ models = []; // no list: the one-token request decides
++ }
++ }
++ if (!error && !models.length) {
++ const model = (JSON.parse(row.models || '{}').chat) || spec.model || DEFAULT_MODEL[provider];
++ try {
++ if (!model) throw Object.assign(new Error('no model is known for this provider'), { code: 'credential.no_model' });
++ const url = PROVIDER_KIND[provider] === 'anthropic' ? `${base}/messages` : `${base}/chat/completions`;
++ const body = PROVIDER_KIND[provider] === 'anthropic'
++ ? { model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }
++ : { model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] };
++ const res = await withDeadline(doFetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) }), timeoutMs);
++ if (!res.ok) {
++ const text = await bodyText(res);
++ const auth = res.status === 401 || res.status === 403;
++ throw Object.assign(new Error(`${auth ? 'the provider refused the key' : 'the provider did not answer the test request'} (${res.status})${text ? `: ${text.slice(0, 200)}` : ''}`), { status: res.status, code: auth ? 'provider.auth' : 'provider.http' });
++ }
++ } catch (err) {
++ status = 'failing';
++ error = {
++ code: err.status === 401 || err.status === 403 ? 'provider.auth'
++ : err.code === 'provider.timeout' ? 'provider.timeout'
++ : err.code === 'EADDRNOTPUBLIC' ? 'credential.endpoint_refused' : 'provider.http',
++ message: scrub(err.message, key),
++ };
++ }
++ }
++ if (!error) balance = await balanceOf(provider, base, key, doFetch, timeoutMs);
++ }
++ const checkedAt = iso(clock.now());
++ await db.prepare('UPDATE subject_credentials SET status = ?, checked_at = ?, last_error = ?, models_count = ? WHERE owner = ? AND subject = ? AND provider = ?')
++ .run(status, checkedAt, error ? error.message : null, models.length, SELF_OWNER, subject, provider);
++ return {
++ ok: status === 'ok', models_count: models.length, sample_models: models.slice(0, 5),
++ latency_ms: Math.max(0, Date.now() - started), balance, error,
++ };
++ }
++
++ return {
++ put, get, remove, admit, forRun, spentToday, providerKeyFor,
++ listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, markFailed,
++ };
}
/** PUT/GET/DELETE /api/v1/credentials/:subject[/:provider] (ai.credential.manage; a service token speaks for itself). */
@@@ -258,4 +546,7 @@ function credentialsRouter({ credentials, auth, registry, sendError }) {
return r;
}
--module.exports = { createCredentials, credentialsRouter, providerKeyFor, aadFor, PROVIDERS, PROVIDER_BASE_URL };
++module.exports = {
++ createCredentials, credentialsRouter, providerKeyFor, aadFor, PROVIDERS, PROVIDER_BASE_URL, PROVIDER_KIND,
++ CREDENTIAL_PROVIDERS, SELF_OWNER, DEFAULT_MODEL, scrub, hintOf,
++};
ddiff --git a/server/events.js b/server/events.js
iindex d72b99e..c3cc733 100644
---- a/server/events.js
++++ b/server/events.js
@@@ -88,6 +88,25 @@ async function runChanged(row) {
return env;
}
++/**
++ * L36 Y8: a provider refused one of a person's own keys (401/403) during a run. The key's status is already
++ * `failing` (server/credentials.js markFailed); this tells the products that may show it. The payload never carries
++ * the key, only a redacted reason. No-op while publishing is off, like every other event.
++ */
++async function credentialFailed({ subject, provider, error, owner = null }) {
++ if (!svc || !dbRef || !subject || !provider) return null;
++ const env = await svc.emitIn(dbRef, {
++ event_type: 'ai.credential.failed',
++ actor: { type: 'service', id: 'ai' },
++ on_behalf_of: { type: 'user', id: subject },
++ subject: { type: 'credential', id: `${subject}:${provider}` },
++ visibility: 'internal',
++ priority: 'important',
++ payload: { subject, provider, status: 'failing', checked_at: iso(Date.now()), error: String(error || 'the provider refused the key').slice(0, 500) },
++ });
++ dbRef.afterCommit(() => svc && svc.kick());
++ return env;
++}
async function status() {
if (!svc) return { enabled: false };
const s = await svc.status();
@@@ -106,4 +125,4 @@ async function stop() {
}
function _reset() { if (svc) svc.stop(); svc = null; queued = 0; }
--module.exports = { init, runChanged, payloadOf, status, stop, _reset };
++module.exports = { init, runChanged, credentialFailed, payloadOf, status, stop, _reset };
ddiff --git a/server/index.js b/server/index.js
iindex c597b75..2d5e680 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -61,10 +61,14 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
require('./usage-samples').init(db, { ...config.billing, clientId: config.console.clientId, networkUrl: config.networkInternalUrl, log });
// ai.preferences (read) and ai.usage_summary (written): Network user modules (server/user-modules.js).
const userModules = require('./user-modules').createUserModules({ db, config, env, fetchImpl, clock, log });
-- // A person's own provider keys (WS-O task 2): stored by the service holding their consent, used by their runs only.
-- const credentials = require('./credentials').createCredentials({ db, config, clock });
++ // A person's own provider keys (WS-O task 2; L36 Y1): stored by the service holding their consent, or by the
++ // person themselves (owner `self`), used by their runs only. The test seam (a fake fetch) is for tests; production
++ // reaches a custom endpoint through the guarded fetcher's egress rules.
++ const credentials = require('./credentials').createCredentials({ db, config, clock, fetchImpl: credentialFetch });
++ // A person's ai.preferences (L36 Y3): only_my_keys, free_first, a price ceiling and a default model per kind.
++ const preferences = require('./person-preferences').createPreferences({ db, clock });
const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
-- const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, tiers, projectSecrets, pool, runBudgets });
++ const runs = createRuns({ db, registry, engine, cache, quotas, config, clock, log, userModules, credentials, preferences, tiers, projectSecrets, pool, runBudgets });
await seed({ registry, quotas, config, env, db });
const interrupted = await runs.recoverInterrupted();
if (interrupted) log.warn(`[ai] marked ${interrupted} interrupted run(s) failed (run.interrupted); callers can retry them`);
@@@ -86,7 +90,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, vault, benchEvents, runBudgets, memory });
++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, vault, benchEvents, runBudgets, memory });
let jwks = null;
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
ddiff --git a/server/me.js b/server/me.js
nnew file mode 100644
iindex 0000000..1e0f3a3
---- /dev/null
++++ b/server/me.js
@@@ -0,0 +1,148 @@
++'use strict';
++/**
++ * The person's own AI routes (L36 Y1/Y3): their provider keys and how their runs route. Contracts
++ * ai.me.read / ai.me.manage, ai.me-credential(-put|-test|-list)@1 and ai.me-preferences@1.
++ *
++ * GET /api/v1/me/credentials
++ * PUT /api/v1/me/credentials/:provider
++ * DELETE /api/v1/me/credentials/:provider
++ * POST /api/v1/me/credentials/:provider/test
++ * GET /api/v1/me/ai/preferences
++ * PUT /api/v1/me/ai/preferences
++ *
++ * Only the person themselves reaches them: their Network user token, or the person session AI's own site already
++ * issues for a run receipt, behind a same-origin check. A service token is refused — including any token carrying
++ * `on_behalf_of`, whatever capabilities it holds (memory rule, AI #204/#205): a Codes run token holds ai.run.create,
++ * so a capability check alone would admit an agent. `ai.me.*` are person capabilities, never a service's.
++ *
++ * Writes are stricter than reads. A user token must carry audience `openvibe.ai` explicitly: a token minted for
++ * `openvibe.network` is every OpenVibe site's session, and a site's server holding it must not be able to point a
++ * person's prompts at its own endpoint or delete their keys. Such a token may still read (hints and settings only).
++ * AI's own person session must prove same-origin for a write: with neither Origin nor Sec-Fetch-Site the write is
++ * refused, so a site cannot use the cookie with a bare request. Reads keep accepting that cookie as before.
++ *
++ * The key is never returned (a four-character hint only), never logged and never put in an error.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { verifyUserToken } = require('openvibe-sdk/auth');
++const { AiError } = require('./util');
++const { bearer } = require('./auth');
++
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const NON_PERSON = /^(svc|app|mod|node):/;
++
++/** The audience a write needs: a token minted for AI itself, never a site's `openvibe.network` session. */
++const WRITE_AUDIENCE = 'openvibe.ai';
++
++function audiencesOf(claims) {
++ const aud = claims && claims.aud;
++ return (Array.isArray(aud) ? aud : aud == null ? [] : [aud]).map(String);
++}
++
++/**
++ * The same-origin check for the person-session cookie (the console's csrfOk rule, without a form token).
++ * A write with neither Origin nor Sec-Fetch-Site is refused: a site can make a bare request with the cookie, so the
++ * absence of both is not proof of same-origin. A read may keep the old rule (it returns hints and settings only).
++ */
++function sameOrigin(req, config, { write = false } = {}) {
++ const site = req.headers['sec-fetch-site'];
++ const origin = req.headers.origin;
++ if (!site && !origin) return !write;
++ if (site && site !== 'same-origin' && site !== 'none') return false;
++ if (origin) {
++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
++ }
++ return true;
++}
++
++function meRouter({ credentials, preferences, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sendError }) {
++ const r = express.Router();
++
++ /** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */
++ function guard(req, res, next) {
++ return Promise.resolve().then(async () => {
++ const ctx = req.ov;
++ const write = req.method !== 'GET' && req.method !== 'HEAD';
++ const token = bearer(req);
++ if (token) {
++ // A service, app, module or node token is a principal whatever capabilities it holds. Checked first so
++ // the answer is explicit, never "invalid token".
++ let service = null;
++ try { service = await auth.verify(token); } catch { service = { ok: false }; }
++ if (service && service.ok) return http.sendProblem(res, 403, 'capability.denied', { detail: "a service token is not accepted on the person's own route", ctx });
++ let claims;
++ try {
++ claims = await verifyUserToken(token, {
++ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),
++ issuer: config.issuer, audience: ['openvibe.network', 'openvibe.ai'], now: clock.now(), log,
++ });
++ } catch (err) {
++ return http.sendProblem(res, err.status || 401, err.code || 'token.invalid', { detail: err.detail || 'invalid token', ctx });
++ }
++ if (NON_PERSON.test(String(claims.sub || '')) || ['service', 'app', 'mod', 'node'].includes(claims.actor_type)) {
++ return http.sendProblem(res, 403, 'capability.denied', { detail: "a principal token is not accepted on the person's own route", ctx });
++ }
++ if (claims.on_behalf_of != null) return http.sendProblem(res, 403, 'capability.denied', { detail: 'a token acting on behalf of someone is not the person themselves', ctx });
++ // A token minted for openvibe.network is every OpenVibe site's session: it may read hints and settings,
++ // but a write needs a token whose aud explicitly names AI itself.
++ if (write && !audiencesOf(claims).includes(WRITE_AUDIENCE)) {
++ return http.sendProblem(res, 403, 'capability.denied', { detail: 'a write needs a user token for audience openvibe.ai; a site session token may only read these settings', ctx });
++ }
++ const subject = SUBJECT_RE.test(String(claims.subject_id || '')) ? claims.subject_id : (SUBJECT_RE.test(String(claims.sub || '')) ? claims.sub : '');
++ if (!subject) return http.sendProblem(res, 401, 'token.invalid', { detail: 'the person token has no usr_ subject', ctx });
++ req.principal = { kind: 'user', sub: claims.sub, subject: { type: 'user', id: subject }, claims };
++ return next();
++ }
++ // The person session AI already issues for a run receipt. Cookie auth needs the same origin, so another
++ // site cannot spend a person's keys with their cookie; a write also needs the origin signal to be present.
++ if (!personSessions) return http.sendProblem(res, 401, 'token.missing', { detail: "the person's own token or session is required", ctx });
++ if (!sameOrigin(req, config, { write })) return http.sendProblem(res, 403, 'capability.denied', { detail: 'the person session is only used from ' + new URL(config.baseUrl).origin + (write ? ', with an Origin or Sec-Fetch-Site header' : ''), ctx });
++ const session = await personSessions.read(req);
++ if (!session) return http.sendProblem(res, 401, 'token.missing', { detail: "the person's own token or session is required", ctx });
++ if (!SUBJECT_RE.test(String(session.subject || ''))) return http.sendProblem(res, 401, 'token.invalid', { detail: 'the session has no usr_ subject', ctx });
++ req.principal = { kind: 'user', subject: { type: 'user', id: session.subject }, username: session.username || null };
++ return next();
++ }).catch((err) => {
++ log.error(`[me] ${req.method} ${req.path}: ${(err && err.stack) || err}`);
++ if (!res.headersSent) http.sendProblem(res, 500, 'internal.error', { detail: 'internal error', ctx: req.ov });
++ });
++ }
++ const personOf = (req) => req.principal.subject.id;
++ const noStore = (res) => res.set('Cache-Control', 'no-store');
++ const run = (fn) => async (req, res) => {
++ try { noStore(res).json(await fn(req)); } catch (err) { sendError(res, err, req.ov); }
++ };
++ const audit = async (req, action, metadata) => {
++ if (!registry) return;
++ try { await registry.audit(personOf(req), action, 'credential', personOf(req), { trace: req.ov && req.ov.traceId, metadata }); } catch { /* audit never breaks the person's request */ }
++ };
++
++ r.get('/api/v1/me/credentials', guard, run(async (req) => ({ credentials: await credentials.listSelf(personOf(req)) })));
++ r.put('/api/v1/me/credentials/:provider', guard, run(async (req) => {
++ const out = await credentials.putSelf(personOf(req), req.params.provider, { ...(req.body || {}), ...(req.body && req.body.provider == null ? { provider: req.params.provider } : {}) });
++ await audit(req, 'credential.self.put', { provider: out.provider, status: out.status, base_url: out.base_url });
++ return out;
++ }));
++ r.delete('/api/v1/me/credentials/:provider', guard, async (req, res) => {
++ try {
++ const gone = await credentials.removeSelf(personOf(req), req.params.provider);
++ if (gone) await audit(req, 'credential.self.delete', { provider: req.params.provider });
++ res.status(gone ? 204 : 404).end();
++ } catch (err) { sendError(res, err, req.ov); }
++ });
++ r.post('/api/v1/me/credentials/:provider/test', guard, run(async (req) => {
++ const out = await credentials.testSelf(personOf(req), req.params.provider);
++ await audit(req, 'credential.self.test', { provider: req.params.provider, ok: out.ok, models_count: out.models_count });
++ return out;
++ }));
++ r.get('/api/v1/me/ai/preferences', guard, run(async (req) => await preferences.get(personOf(req))));
++ r.put('/api/v1/me/ai/preferences', guard, run(async (req) => {
++ const out = await preferences.put(personOf(req), req.body || {});
++ await audit(req, 'ai.preferences.put', { only_my_keys: out.only_my_keys, free_first: out.free_first, price_ceiling_usd_per_mtok: out.price_ceiling_usd_per_mtok });
++ return out;
++ }));
++ return r;
++}
++
++module.exports = { meRouter, sameOrigin };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 5b50b2b..e783d3f 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -12,8 +12,10 @@
*
* /api/health and /api/ready are probes. The operator/admin API and the attribution-quota API are
* internal (staff/service tokens): they stay out. Credentials are deliberately not documented at all
-- * — no credential, provider secret or secret reference field appears in this document. The
-- * OpenAI-compatible /v1/chat/completions and /v1/models are outside /api and stay out of it too.
++ * — no credential, provider secret or secret reference field appears in this document. The person's
++ * own /api/v1/me/* routes (their provider keys, and the preferences that route them) are person-only
++ * and carry a key on PUT, so they stay out for the same reason. The OpenAI-compatible
++ * /v1/chat/completions and /v1/models are outside /api and stay out of it too.
*
* Request fields are what server/api/runs.js and server/api/actions.js read; the run properties are
* server/runs.js decode(). Nothing is inferred.
ddiff --git a/server/person-preferences.js b/server/person-preferences.js
nnew file mode 100644
iindex 0000000..5380145
---- /dev/null
++++ b/server/person-preferences.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * A person's ai.preferences (L36 Y3; Contracts ai.me-preferences@1): only_my_keys, free_first, a price ceiling for
++ * the network's paid fallback, and a default model (or `auto`) per kind of work. Stored per person in
++ * ai_person_preferences (migrations/0020), JSON so another preference adds without a migration.
++ *
++ * A row that predates the table or a field it never saw reads the default, never undefined. There is no default key
++ * and no network budget here: own keys are free to their owner.
++ */
++const contracts = require('openvibe-contracts');
++const { AiError, iso } = require('./util');
++
++const DEFAULTS = Object.freeze({
++ only_my_keys: false,
++ free_first: true,
++ price_ceiling_usd_per_mtok: null,
++ defaults: Object.freeze({ text: 'auto', code: 'auto', vision: 'auto', image: 'auto' }),
++});
++
++/** The same rules ai.me-preferences@1 states, for a service whose contracts pin predates 0.257.0. */
++function manualPreferences(body) {
++ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
++ if (typeof body.only_my_keys !== 'boolean' || typeof body.free_first !== 'boolean') return 'only_my_keys and free_first are booleans';
++ if (body.price_ceiling_usd_per_mtok != null && !(Number(body.price_ceiling_usd_per_mtok) >= 0 && Number(body.price_ceiling_usd_per_mtok) <= 1000)) return 'price_ceiling_usd_per_mtok must be 0-1000 or null';
++ const d = body.defaults;
++ if (!d || typeof d !== 'object' || Array.isArray(d)) return 'defaults is an object';
++ for (const k of ['text', 'code', 'vision', 'image']) {
++ if (typeof d[k] !== 'string' || !d[k].length || d[k].length > 120) return `defaults.${k} must be a model id or auto (1-120 characters)`;
++ }
++ return null;
++}
++
++function createPreferences({ db, clock = { now: () => Date.now() } }) {
++ const read = async (subject) => {
++ const row = await db.prepare('SELECT preferences FROM ai_person_preferences WHERE subject = ?').get(String(subject));
++ let stored = {};
++ try { stored = row ? JSON.parse(row.preferences || '{}') : {}; } catch { stored = {}; }
++ return {
++ only_my_keys: stored.only_my_keys === true,
++ free_first: stored.free_first == null ? DEFAULTS.free_first : stored.free_first === true,
++ price_ceiling_usd_per_mtok: typeof stored.price_ceiling_usd_per_mtok === 'number' ? stored.price_ceiling_usd_per_mtok : null,
++ defaults: { ...DEFAULTS.defaults, ...(stored.defaults && typeof stored.defaults === 'object' ? stored.defaults : {}) },
++ updated_at: row ? row.updated_at : null,
++ };
++ };
++
++ async function get(subject) { return await read(subject); }
++
++ async function put(subject, body) {
++ // ai.me-preferences@1 arrives with openvibe-contracts 0.257.0; before that pin the shape is checked here.
++ let v = null;
++ try { v = contracts.schema('ai.me-preferences@1') ? contracts.validate('ai.me-preferences@1', body) : null; } catch { v = null; }
++ const manual = v ? null : manualPreferences(body);
++ if ((v && !v.valid) || manual) throw new AiError(422, 'input.invalid', 'body does not match ai.me-preferences@1', { errors: v ? v.errors : [{ path: '', message: manual }] });
++ const value = {
++ only_my_keys: body.only_my_keys === true,
++ free_first: body.free_first === true,
++ price_ceiling_usd_per_mtok: body.price_ceiling_usd_per_mtok == null ? null : Number(body.price_ceiling_usd_per_mtok),
++ defaults: {
++ text: String(body.defaults.text), code: String(body.defaults.code),
++ vision: String(body.defaults.vision), image: String(body.defaults.image),
++ },
++ };
++ const at = iso(clock.now());
++ const prev = await db.prepare('SELECT created_at FROM ai_person_preferences WHERE subject = ?').get(String(subject));
++ await db.prepare(`INSERT INTO ai_person_preferences (subject, preferences, created_at, updated_at)
++ VALUES (?, ?, ?, ?)
++ ON CONFLICT(subject) DO UPDATE SET preferences = excluded.preferences, updated_at = excluded.updated_at`)
++ .run(String(subject), JSON.stringify(value), prev ? prev.created_at : at, at);
++ return await read(subject);
++ }
++
++ return { get, put, DEFAULTS };
++}
++
++module.exports = { createPreferences, DEFAULTS };
ddiff --git a/server/person-session.js b/server/person-session.js
iindex 573c424..340e9a9 100644
---- a/server/person-session.js
++++ b/server/person-session.js
@@@ -1,9 +1,11 @@
'use strict';
/**
* Person sessions for the run receipt page (GET /runs/:id): a random 256-bit cookie id, stored as its
-- * SHA-256; like the console's sessions, the cookie itself is never written down. A person session is
-- * read-only and only opens runs whose on_behalf_of is its subject; it grants no capability and never
-- * opens the operator console.
++ * SHA-256; like the console's sessions, the cookie itself is never written down. In the run pages a
++ * person session only opens runs whose on_behalf_of is its subject. It is the person's own sign-in on
++ * AI's site, so behind a same-origin check it also reaches their own /api/v1/me/* settings
++ * (server/me.js: their provider keys and routing preferences) — never a service route, and it grants
++ * no capability and never opens the operator console.
*
* cookie `__Host-ovai_person` (plain `ovai_person` over http in development): host-only, Path=/,
* HttpOnly, SameSite=Lax, Secure when BASE_URL is https, Max-Age = AI_PERSON_SESSION_TTL_MIN.
ddiff --git a/server/providers/index.js b/server/providers/index.js
iindex de974db..1376516 100644
---- a/server/providers/index.js
++++ b/server/providers/index.js
@@@ -44,7 +44,39 @@ function rateLimited(err) {
}
/** The model a person's credential uses when they stored none, per provider (executeWithCredential). */
--const CREDENTIAL_DEFAULT_MODEL = { anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };
++const { CREDENTIAL_PROVIDERS, PROVIDER_KIND, DEFAULT_MODEL } = require('../credentials');
++const CREDENTIAL_DEFAULT_MODEL = { ...DEFAULT_MODEL, anthropic: 'claude-haiku-5-5', openai: 'gpt-4o-mini', deepseek: 'deepseek-chat', minimax: 'MiniMax-M2', openrouter: 'openrouter/auto' };
++
++/** The key as explain names it, never the key itself (L36 Y1/Y7). */
++const paidByOf = (cred) => `your ${cred.provider} key …${String(cred.hint || '').slice(-4) || '????'}`;
++/** The kind of work a run does, for the person's ai.me-preferences.defaults. */
++function workKind(operation, req, ctx = {}) {
++ if (operation === 'image') return 'image';
++ if (req && req.image) return 'vision';
++ const hint = `${ctx.workflowKey || ''} ${ctx.routeKey || ''}`;
++ return /\b(code|coding|codes|build|design|program|developer)\b/i.test(hint) ? 'code' : 'text';
++}
++/** Can one stored key serve this operation? (executeWithCredential's own rule, checked before it is called.) */
++function credentialCanServe(provider, operation, req) {
++ if (!CREDENTIAL_PROVIDERS.includes(provider)) return false;
++ const text = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'];
++ if (text.includes(operation)) return true;
++ if (operation === 'messages') return PROVIDER_KIND[provider] === 'anthropic' || provider === 'custom';
++ if (operation === 'complete') return PROVIDER_KIND[provider] === 'openai' || provider === 'custom';
++ return false;
++}
++/** The model a self key should use for this run: the person's default for the kind, else their stored model. */
++function selfModelFor(cred, operation, req, ctx, prefs) {
++ const kind = workKind(operation, req, ctx);
++ const choice = prefs && prefs.defaults ? prefs.defaults[kind] : 'auto';
++ const stored = cred.models || {};
++ const role = ctx.role || ((/^live\.(chat|vision|director|summary|legacy)$/.exec(ctx.routeKey || '') || [])[1]) || null;
++ const model = (choice && choice !== 'auto') ? choice
++ : (req.image && stored.vision) || (role && stored[role]) || stored[kind] || stored.chat
++ || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'gpt-4o-mini';
++ const why = choice && choice !== 'auto' ? `your default ${kind} model` : (stored[kind] || stored.chat ? 'the model you stored for this key' : "the provider's default");
++ return { model, kind, why };
++}
function createProviderPool({ db, registry, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, credentialFetch = null, machineModels = null, bench = null }) {
// A person's own machine as their model (credential provider node, server/machines.js).
@@@ -411,8 +443,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (ctx.logRequest) ctx.logRequest({ ...base, model_key: result.model, status: 'ok', output_hash: sha256(result.text || ''), tokens_in: usage.input, tokens_out: usage.output, tokens_cached: 0, tokens_estimated: 0, cost_usd: 0, latency_ms: latency });
return { result: { text: result.text, json: null, model: result.model, usage }, provider: cred.providerKey, providerKind: 'node', model: result.model, fallbackUsed: false, usage, cost: 0, latencyMs: latency, synthetic: false, tried: [], startedAt: t0,
explain: { objective: 'own-machine', reasons: [cred.machine === 'any' ? 'the fastest of the person\'s own machines that answered, by their choice' : 'the person\'s own machine, by their choice'],
++ paid_by: 'your machine',
selected: { provider: 'node', machine: result.machine || cred.machine, model: result.model },
-- candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })) } };
++ candidates: (result.tried || []).map((x) => ({ provider: 'node', machine: x.machine, skipped: x.error })),
++ attempts: [{ key: 'your machine', provider: 'node', model: result.model, why: 'your own machine', status: 'ok', error: null }] } };
} catch (err) {
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'error', latency_ms: Date.now() - t0, error: String(err && err.message || err).slice(0, 500) });
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
@@@ -426,11 +460,11 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// A project's own provider (ADR-056 secret, ai.complete) passes a Chat Completions request through as written.
// The OpenAI-format providers: a Chat Completions request is passed through as written (Codes C3: OpenRouter
// speaks that format too, so a person's OpenRouter key answers it as well).
-- const completing = operation === 'complete' && ['openai', 'openrouter'].includes(cred.provider);
++ const completing = operation === 'complete' && (PROVIDER_KIND[cred.provider] === 'openai' || cred.provider === 'custom');
// Codes C3: POST /v1/messages on the person's own key. Only a provider that speaks Anthropic's Messages API serves
// it — Anthropic itself, DeepSeek and MiniMax (their own Anthropic-compatible endpoints) and a custom endpoint.
const messaging = operation === 'messages';
-- const anthropicFormat = ['anthropic', 'deepseek', 'minimax', 'custom'].includes(cred.provider);
++ const anthropicFormat = PROVIDER_KIND[cred.provider] === 'anthropic' || cred.provider === 'anthropic' || cred.provider === 'custom';
if (messaging && !anthropicFormat) throw new AiError(422, 'credential.unsupported', `the ${cred.provider} credential does not speak the Messages API`);
if (!messaging && !completing && !['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich'].includes(operation)) throw new AiError(422, 'credential.unsupported', `a person's own key is used for text only, not ${operation}`);
const guardedFetch = credentialFetch || require('./guarded-fetch').guardedFetch;
@@@ -439,9 +473,9 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const role = wanted && cred.models[wanted] ? wanted : null;
// On the person's Anthropic key the model the caller asked for is theirs to choose; elsewhere the model they stored
// is the one their provider serves (a Claude Code model name does not exist at DeepSeek).
-- const model = messaging
++ const model = ctx.defaultModel || (messaging
? ((cred.provider === 'anthropic' || cred.provider === 'custom') && req.model) || cred.models.chat || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'claude-haiku-4-5-20251001'
-- : (req.image && cred.models.vision) || (role && cred.models[role]) || cred.models.chat || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'gpt-4o-mini';
++ : (req.image && cred.models.vision) || (role && cred.models[role]) || cred.models.chat || CREDENTIAL_DEFAULT_MODEL[cred.provider] || 'gpt-4o-mini');
const record = { key: cred.providerKey, kind: cred.provider, base_url: cred.base_url, capabilities: cred.capabilities || ['chat', 'json', 'vision'], timeout_ms: 60000 };
const a = anthropicFormat ? createAnthropicProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch }) : createOpenAiProvider(record, { apiKey: cred.apiKey, fetchImpl: guardedFetch });
const base = { operation, provider_key: cred.providerKey, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: 0, prompt_hash: ctx.promptHash || null };
@@@ -451,6 +485,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const t = String(text == null ? '' : text);
return cred.apiKey ? t.split(String(cred.apiKey)).join('[redacted]') : t;
};
++ const paidBy = paidByOf(cred);
++ const explain = cred.explain || {
++ objective: 'own-key', reasons: ['your own key: no shared provider is called'],
++ paid_by: paidBy, selected: `${cred.provider}/${model}`, candidates: [],
++ attempts: [{ key: paidBy, provider: cred.provider, model, why: 'your own key', status: 'ok', error: null }],
++ };
const timeoutMs = Math.max(1000, Math.min(req.timeoutMs || 60000, 120000));
const call = messaging ? a.messages : completing ? a.complete : a.chat;
let streamed = false;
@@@ -468,12 +508,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
ctx.logRequest({ ...base, model_key: result.model || model, status: 'ok', output_hash: sha256(result.json || result.text || ''), tokens_in: usage.input || 0, tokens_out: usage.output || 0,
tokens_cached: usage.cached || 0, tokens_estimated: usage.estimated ? 1 : 0, cost_usd: cost, latency_ms: latency });
}
-- return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [], startedAt: t0,
-- ...(cred.explain ? { explain: cred.explain } : {}) };
++ return { result, provider: cred.providerKey, providerKind: cred.provider, model: result.model || model, fallbackUsed: false, usage, cost, latencyMs: latency, synthetic: false, tried: [],
++ startedAt: t0, explain: { ...explain, selected: `${cred.provider}/${result.model || model}` } };
} catch (err) {
lastErr = err;
if (ctx.signal && ctx.signal.aborted) throw new AiError(409, 'run.cancelled', 'run was cancelled');
if (ctx.logRequest) ctx.logRequest({ ...base, status: err && err.code === 'provider.timeout' ? 'timeout' : 'error', latency_ms: Date.now() - t0, error: scrub(err && err.message || err).slice(0, 500) });
++ // L36 Y8: the provider refused the key (401/403). The key becomes failing and the event says so.
++ if (err && (err.status === 401 || err.status === 403) && typeof ctx.onCredentialFailed === 'function') {
++ await ctx.onCredentialFailed({ owner: cred.owner || null, subject: cred.subject || null, provider: cred.provider, providerKey: cred.providerKey, error: scrub(err.message) });
++ }
if (err && err.code === 'EADDRNOTPUBLIC') throw new AiError(422, 'credential.endpoint_refused', scrub(err.message));
if (streamed) throw new AiError(502, 'provider.stream_broken', `the answer broke off after it began streaming: ${scrub(err && err.message || err).slice(0, 200)}`);
if (attempt < config.providerRetries && retryable(err)) { await sleep(config.retryDelayMs, ctx.signal).catch(() => {}); continue; }
@@@ -511,6 +555,30 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
return explain.bench || null;
}
++ /** The providers that could serve this request, for the ai.needs_key answer (L36 Y1). */
++ async function servingProviders(route, features, operation, req, selfList = []) {
++ const out = new Set();
++ const add = (key) => { if (CREDENTIAL_PROVIDERS.includes(key)) out.add(key); };
++ const scope = route.constraints && route.constraints.provider_scope;
++ if (route.primary && route.primary.provider) {
++ for (const c of [route.primary, ...(route.fallbacks || [])]) if (c && c.provider) add(c.provider);
++ } else {
++ for (const p of await registry.listProviders()) {
++ if (p.key === 'stub' || !appOffered({}, p)) continue;
++ if (scope === 'local' && !isLocalProvider(p)) continue;
++ if (scope === 'paid' && isFreeProvider(p)) continue;
++ const caps = await offeredCapabilities(p);
++ if (features.some(f => !caps.includes(f))) continue;
++ add(p.key);
++ }
++ }
++ for (const c of selfList) if (credentialCanServe(c.provider, operation, req)) add(c.provider);
++ // A route whose network providers are all unknown to the credential table still names the console's providers:
++ // that is what the person can add under Your AI.
++ if (!out.size) for (const id of CREDENTIAL_PROVIDERS) if (credentialCanServe(id, operation, req)) out.add(id);
++ return [...out].sort();
++ }
++
async function execute(route, operation, req, ctx = {}) {
if (ctx.credential) return await executeWithCredential(ctx.credential, operation, req, ctx);
const features = [operation];
@@@ -523,7 +591,62 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
// after that is final, never retried or handed to a fallback whose words would follow the first one's.
let streamed = false;
const onDelta = typeof req.onDelta === 'function' ? (d) => { streamed = true; req.onDelta(d); } : null;
++ // L36 Y1: a run on behalf of a person tries the person's OWN keys first, for the providers that can serve the
++ // request. With only_my_keys it never falls back; otherwise the network is the fallback and the explain says so.
++ const prefs = ctx.selfPreferences || {};
++ const selfAttempts = [];
++ const selfList = Array.isArray(ctx.selfCredentials) ? ctx.selfCredentials : [];
++ if (selfList.length || (ctx.selfSubject && prefs.only_my_keys)) {
++ const order = [...selfList].sort((a, b) => {
++ const rank = (x) => (x.status === 'failing' ? 2 : x.status === 'untested' ? 1 : 0);
++ return rank(a) - rank(b) || String(a.provider).localeCompare(String(b.provider));
++ });
++ for (const cred of order) {
++ if (!credentialCanServe(cred.provider, operation, req)) {
++ selfAttempts.push({ key: paidByOf(cred), provider: cred.provider, model: null, why: 'your own key', status: 'skipped', error: `this key cannot serve ${operation}` });
++ continue;
++ }
++ const pick = selfModelFor(cred, operation, req, ctx, prefs);
++ const key = paidByOf(cred);
++ try {
++ const out = await executeWithCredential(cred, operation, req, { ...ctx, defaultModel: pick.model });
++ const attempts = [...selfAttempts, { key, provider: cred.provider, model: pick.model, why: pick.why, status: 'ok', error: null }];
++ return {
++ ...out, fallbackUsed: false,
++ explain: {
++ objective: 'own-keys', reasons: ['your own keys are tried before the network\'s providers'],
++ paid_by: key, selected: `${cred.provider}/${pick.model}`, candidates: [], attempts,
++ },
++ };
++ } catch (err) {
++ const detail = String(err && (err.detail || err.message) || err).slice(0, 300);
++ selfAttempts.push({ key, provider: cred.provider, model: pick.model, why: pick.why, status: 'failed', error: detail });
++ }
++ }
++ if (prefs.only_my_keys) {
++ const providers = await servingProviders(route, features, operation, req, selfList);
++ const names = providers.length ? providers.join(', ') : 'any provider that can serve this request';
++ throw new AiError(422, 'ai.needs_key',
++ `only_my_keys is on and none of your keys could serve this request; add a key for ${names} under Your AI`,
++ { providers, paid_by: null, attempts: selfAttempts, explain: { objective: 'own-keys', reasons: ['only_my_keys: no network fallback'], paid_by: null, selected: null, candidates: [], attempts: selfAttempts } });
++ }
++ }
let { order: list, explain } = await candidates(route, features, operation, ctx);
++ // L36 Y3: free_first puts the free tiers ahead of metered ones for this person's fallback. Own-key attempts above
++ // are already free to them, so this only reorders the network.
++ if (prefs.free_first && ctx.selfSubject) {
++ const free = new Map();
++ for (const c of list) {
++ if (c.provider === 'stub') { free.set(c.provider, true); continue; }
++ const p = await registry.getProvider(c.provider);
++ free.set(c.provider, !p || isFreeProvider(p));
++ }
++ list = [...list].sort((a, b) => Number(free.get(b.provider)) - Number(free.get(a.provider)));
++ }
++ const attempts = [...selfAttempts];
++ explain.paid_by = 'openvibe';
++ explain.attempts = attempts;
++ explain.reasons = [...(explain.reasons || []), ...(selfAttempts.length ? ['your own keys did not answer; the network is the fallback'] : [])];
// A first-party service's generate run may name one offer from GET /api/v1/routes. The run then
// tries that offer alone: no fallback to another model, so the caller gets the model it asked for
// or an explicit failure. server/runs.js validates the pin against this same offer list first.
@@@ -544,10 +667,24 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const model = c.model || (p && p.default_model) || null;
const fallback = i > 0;
const base = { operation, provider_key: c.provider, model_key: model, route_key: ctx.routeKey || null, route_version: ctx.routeVersion || null, fallback: fallback ? 1 : 0, prompt_hash: ctx.promptHash || null, input_hash: ctx.inputHash || null };
++ // L36 Y3: a price ceiling caps the network's fallback; the person's own keys are never capped (above).
++ if (prefs.price_ceiling_usd_per_mtok != null) {
++ const cards = await rateCardsFor(c.provider, model);
++ const outputPrice = cards ? Number(rates.pricesOf(cards).out) || 0 : 0;
++ if (outputPrice > Number(prefs.price_ceiling_usd_per_mtok)) {
++ const whyPrice = `over_price_ceiling (${outputPrice} > ${prefs.price_ceiling_usd_per_mtok} USD/Mtok)`;
++ statFor(c.provider).skipped++;
++ tried.push({ provider: c.provider, skipped: 'over_price_ceiling' });
++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the router chose it', status: 'skipped', error: whyPrice });
++ if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: 'over_price_ceiling' });
++ continue;
++ }
++ }
const why = await skipReason(p, a, features);
if (why) {
statFor(c.provider).skipped++;
tried.push({ provider: c.provider, skipped: why });
++ attempts.push({ key: 'openvibe', provider: c.provider, model, why: 'the router chose it', status: 'skipped', error: why });
if (ctx.logRequest) ctx.logRequest({ ...base, status: 'skipped', skip_reason: why });
continue;
}
@@@ -588,7 +725,10 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
});
}
explain.selected = c.provider;
-- return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback, usage, cost, latencyMs: latency, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };
++ explain.paid_by = 'openvibe';
++ attempts.push({ key: 'openvibe', provider: c.provider, model: result.model || model, why: fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route', status: 'ok', error: null });
++ explain.attempts = attempts;
++ return { result, provider: c.provider, providerKind: p.kind, model: result.model || model, fallbackUsed: fallback || selfAttempts.some(a => a.status !== 'skipped'), usage, cost, latencyMs: latency, synthetic: Boolean(result.synthetic || a.synthetic), tried, startedAt: started, explain };
} catch (err) {
lastErr = err;
if (ctx.signal && ctx.signal.aborted) {
@@@ -609,9 +749,12 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
if (lastErr && lastErr.code === 'media.unreadable') unreadable = lastErr;
if (!callerFault(lastErr) && !rateLimited(lastErr)) await recordFailure(c.provider, lastErr);
tried.push({ provider: c.provider, error: String(lastErr && lastErr.message || lastErr).slice(0, 200) });
++ attempts.push({ key: 'openvibe', provider: c.provider, model: lastErr && lastErr.model || model, why: fallback ? 'the next network provider after an earlier attempt failed' : 'the first network offer for this route', status: lastErr && lastErr.code === 'provider.timeout' ? 'timeout' : 'failed', error: String(lastErr && lastErr.message || lastErr).slice(0, 300) });
log.warn(`[ai] ${operation} via ${c.provider}/${model || '-'} failed: ${lastErr && lastErr.message}`);
}
explain.selected = null;
++ explain.paid_by = 'openvibe';
++ explain.attempts = attempts;
// The media itself could not be read: no provider will do better, and the caller should hear why (and not
// retry as if a provider were down).
if (unreadable) {
ddiff --git a/server/runs.js b/server/runs.js
iindex a6bde91..50a0bcd 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -99,7 +99,7 @@ function routePinRequest(value, principal, wf) {
return { provider, model };
}
--function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null }) {
++function createRuns({ db, registry, engine, cache, quotas, config, clock = { now: () => Date.now() }, log = console, userModules = null, credentials = null, preferences: personPreferences = null, tiers = null, projectSecrets = null, pool = null, runBudgets = null }) {
const inflight = new Map(); // run id -> { controller, promise, release, caller }
const queue = []; // run ids waiting for the worker
let active = 0;
@@@ -247,6 +247,11 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
if (wf.key !== 'ai.complete') throw new AiError(422, 'input.invalid', 'a project provider is used for ai.complete only');
if (!projectSecrets || !projectSecrets.available()) throw new AiError(503, 'project_secret.unavailable', 'project secrets are not configured on this service');
}
++ // L36 Y1: a run on behalf of a person tries that person's OWN keys first (owner `self`). When they hold one,
++ // the run is uncached and no network budget or tier holds it: their own key is free to them, and a $0 balance
++ // must never block it. If it falls back to the network, the usage is still recorded after the fact.
++ const personSubject = body.on_behalf_of && body.on_behalf_of.type === 'user' && typeof body.on_behalf_of.id === 'string' ? body.on_behalf_of.id : null;
++ const selfKeys = Boolean(credentials && personSubject && credentialSubject === null && !projectProvider && !pinned && await credentials.hasSelf(personSubject));
// The budget a Codes run token carries (Codes C3): a run that is over its budget is refused before
// any provider call. The budget lives here, not in the token, so the holder of the token cannot raise it.
if (runBudgets && principal.jti) {
@@@ -274,23 +279,25 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
source_service: requester.type === 'service' ? requester.id : null,
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
-- options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned, debug: Boolean(options.debug), input_retained: kept.full,
++ options: JSON.stringify({ cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys, debug: Boolean(options.debug), input_retained: kept.full,
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
++ ...(selfKeys ? { self_subject: personSubject } : {}),
...(projectProvider ? { project_provider: projectProvider } : {}), ...(pinned ? { pinned_offer: pinned } : {}) }), created_at: now,
};
base.input = stableStringify(kept.value);
// Cache (scoped; never crosses requester/actor/target/attribution)
// A pinned run is never served from (or written to) the shared cache: the caller asked for THIS model,
-- // and another model's answer under the same prompt would silently break the pin.
-- const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned ? null : cache.scopeFor(wf.cache_mode, ctx);
++ // and another model's answer under the same prompt would silently break the pin. A run that may use the
++ // person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.
++ const scope = options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys ? null : cache.scopeFor(wf.cache_mode, ctx);
let cacheKey = null;
if (scope) {
cacheKey = cache.keyFor({ scope, workflowKey: wf.key, workflowVersion: wf.version, templateVersion: base.template_version, routeKey: base.route_key, routeVersion: base.route_version, inputHash });
const hit = await cache.get(cacheKey, scope);
if (hit) {
await db.tx(async () => {
-- const cacheExplain = { objective: 'cache', reasons: ["the answer was already in this scope's cache; no provider was called"], selected: null, candidates: [] };
++ const cacheExplain = { objective: 'cache', reasons: ["the answer was already in this scope's cache; no provider was called"], paid_by: 'cache', selected: null, candidates: [], attempts: [] };
await db.prepare(`INSERT INTO runs (id, workflow_key, workflow_version, template_key, template_version, route_key, route_version, status, requester_type, requester_id, on_behalf_of, attribution,
source_service, target, target_key, input, input_hash, output, model_key, cache_key, cached_from, retry_of, idempotency_key, trace_id, request_id, options, explain, created_at, started_at, finished_at, citations_count)
VALUES (@id, @workflow_key, @workflow_version, @template_key, @template_version, @route_key, @route_version, 'cached', @requester_type, @requester_id, @on_behalf_of, @attribution,
@@@ -315,10 +322,12 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
admit(caller);
// A run with a person's own key spends their money, not the shared quotas (its own budget was checked above).
// The tier budgets (server/govern.js, AI_GOVERN_TIERS) hold first, so a run they refuse is not counted by reserve().
-- const hold = credentialSubject !== null || projectProvider || !tiers ? null : await tiers.admit(ctx, id);
++ // L36 Y1: a run that may use the person's own key holds neither — their key is free to them, and a $0 balance
++ // anywhere must never block it. If it falls back to the network, the usage is recorded after the run.
++ const hold = credentialSubject !== null || projectProvider || selfKeys || !tiers ? null : await tiers.admit(ctx, id);
let reserved;
try {
-- reserved = credentialSubject !== null ? null : await quotas.reserve(ctx);
++ reserved = credentialSubject !== null || selfKeys ? null : await quotas.reserve(ctx);
} catch (err) { if (hold) await tiers.release(hold); throw err; }
try {
@@@ -375,7 +384,7 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
return {
providerKey: `project:${ctx.projectId}`, provider: 'openai', base_url: spec.base_url, apiKey: value, models: { chat: spec.model },
capabilities: ['chat', 'json', 'vision', 'tools'],
-- explain: { objective: null, reasons: [`the project's own provider at ${new URL(spec.base_url).origin} (project secret ${spec.secret}, ${environment})`], selected: `project:${ctx.projectId}`, candidates: [] },
++ explain: { objective: null, reasons: [`the project's own provider at ${new URL(spec.base_url).origin} (project secret ${spec.secret}, ${environment})`], paid_by: 'project', selected: `project:${ctx.projectId}`, candidates: [], attempts: [] },
};
}
@@@ -409,6 +418,16 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
}
const credential = opts.credential_subject ? await credentials.forRun(row.requester_id, opts.credential_subject, opts.credential_provider || null)
: opts.project_provider ? await projectCredential(opts.project_provider, ctx) : null;
++ // L36 Y1/Y8: the person's own keys, and what their preferences say about the network fallback. A provider
++ // refusing one (401/403) marks it failing and publishes ai.credential.failed, never the key.
++ const selfSubject = opts.self_subject || null;
++ const selfCredentials = selfSubject && credentials ? await credentials.selfForAllRuns(selfSubject) : null;
++ const selfPreferences = selfSubject && personPreferences ? await personPreferences.get(selfSubject) : null;
++ const onCredentialFailed = credentials ? async (failure) => {
++ if (!failure || !failure.owner || !failure.subject || !failure.provider) return;
++ const changed = await credentials.markFailed(failure.owner, failure.subject, failure.provider, failure.error);
++ if (changed) await events.credentialFailed({ subject: failure.subject, provider: failure.provider, error: failure.error, owner: failure.owner });
++ } : undefined;
const r = await engine.execute(wf, row, input, { signal: controller.signal, logRequest, debugRaw: Boolean(config.debugRawLog && opts.debug), inputHash: row.input_hash, preferences,
// A developer app's run (ADR-014): the provider pool excludes a person's byok key, and the run is
// limited to free and local capacity when it is a sandbox run or when no tier budget governs its
@@@ -417,6 +436,8 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
// Who asked, for a step that must not call the caller back (research: Search's own web ladder).
requesterType: ctx.requesterType, requesterId: ctx.requesterId,
...(ctx.routePin ? { routePin: ctx.routePin } : {}),
++ ...(onCredentialFailed ? { onCredentialFailed } : {}),
++ ...(selfSubject ? { selfSubject, selfCredentials: selfCredentials || [], selfPreferences } : {}),
...(credential ? { credential, role: input && typeof input.role === 'string' ? input.role : null } : {}),
...(typeof ctx.onDelta === 'function' ? { onDelta: ctx.onDelta } : {}) });
if (controller.signal.aborted) throw new AiError(409, 'run.cancelled', 'cancelled');
@@@ -432,7 +453,10 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
return d;
});
if (!done.changes) return; // cancelled while finishing
-- if (r.provider && reserved) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id, hold });
++ // A self-key run has no reservation and no tier hold; if it fell back to the network, its usage is still
++ // recorded (the fallback is named in explain). A run that used the person's own key spends nothing here.
++ const usedOwnKey = String(r.provider || '').startsWith('byo:self:');
++ if (r.provider && (reserved || (opts.self_subject && !usedOwnKey))) await quotas.account(ctx, reserved, { provider: r.provider, model: r.model, tokensIn: r.usage.input, tokensOut: r.usage.output, tokensCached: r.usage.cached, cost: r.cost, attempts: logged, runId: id, traceId: row.trace_id, hold });
// What the run spent goes on the budget of its run token (Codes C3): the same tokens and cost the
// quota accounting counts, so the next request of a run over its budget is refused.
if (runBudgets && ctx.runJti) await runBudgets.add(ctx.runJti, { tokens: r.usage.input + r.usage.output, cost: r.cost });
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex b3a88ea..b32a1b2 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -137,3 +137,37 @@ t.test('their runs, quota and module marker go; the spend ledger and the audit t
assert.ok(confirmation.body.retained.usage_counters >= 1, 'the kept ledger is counted as retained');
} finally { await h.stop(); await stub.close(); }
});
++
++t.test('L36: the person\'s self credentials export without the key, and deletion erases them with their preferences', async () => {
++ const stub = await startNetworkStub();
++ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
++ try {
++ const SECRET_KEY = 'sk-self-ada-0123456789LEAK';
++ const sealed = `v2:${Buffer.alloc(12).toString('base64')}:${Buffer.alloc(16).toString('base64')}:${Buffer.from(SECRET_KEY).toString('base64')}`;
++ await h.db.exec(`INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, status, checked_at, last_error, models_count, created_at, updated_at)
++ VALUES ('self', $1, 'anthropic', $2, '…LEAK', '{"chat":"m"}', 'ok', '2026-10-31T09:00:00.000Z', NULL, 3, '2026-10-01', '2026-10-02')`, [A, sealed]);
++ await h.db.exec(`INSERT INTO ai_person_preferences (subject, preferences, created_at, updated_at)
++ VALUES ($1, '{"only_my_keys":true,"free_first":false,"price_ceiling_usd_per_mtok":5,"defaults":{"text":"auto","code":"auto","vision":"auto","image":"auto"}}', '2026-10-01', '2026-10-02')`, [A]);
++ const deliver = async (event) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
++ return { status: res.status, json: await res.json().catch(() => null) };
++ };
++
++ const r = await deliver(ev('evt_01JZ0000000000000000000E03', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX3', subject: A }));
++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);
++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX3/parts');
++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
++ const cred = files['provider-credentials.json'].find((c) => c.owner === 'self');
++ assert.deepStrictEqual([cred.provider, cred.key_hint, cred.status, cred.models_count, cred.checked_at], ['anthropic', '…LEAK', 'ok', 3, '2026-10-31T09:00:00.000Z']);
++ assert.ok(!('key_enc' in cred) && !JSON.stringify(part.body).includes(SECRET_KEY), 'the key is never exported');
++ assert.strictEqual(files['ai-preferences.json'][0].preferences.includes('only_my_keys'), true, 'the preferences export');
++
++ const d = await deliver(ev('evt_01JZ0000000000000000000D03', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DX3', subject: A, aliases: [] }));
++ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
++ assert.strictEqual(Number(await h.db.value("SELECT count(*)::int FROM subject_credentials WHERE owner = 'self' AND subject = $1", [A])), 0, 'the self key is erased');
++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM ai_person_preferences WHERE subject = $1', [A])), 0, 'the preferences are erased');
++ } finally { await h.stop(); await stub.close(); }
++});
++
++t.run();
ddiff --git a/test/events.test.js b/test/events.test.js
iindex 4621106..65c2a7e 100644
---- a/test/events.test.js
++++ b/test/events.test.js
@@@ -102,6 +102,21 @@ t.test('a failing outbox prune is caught, not left as an unhandled rejection', a
}
});
++t.test('a refused own key queues ai.credential.failed, never the key (L36 Y8)', async () => {
++ const events = require('../server/events');
++ const subject = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++ const env = await events.credentialFailed({ subject, provider: 'anthropic', error: 'the provider refused the key (401)' });
++ assert.ok(env && env.event_id, 'the event was queued');
++ const ev = (await queued()).find((e) => e.event_id === env.event_id);
++ assert.strictEqual(ev.event_type, 'ai.credential.failed');
++ assert.deepStrictEqual(ev.on_behalf_of, { type: 'user', id: subject });
++ assert.deepStrictEqual([ev.payload.subject, ev.payload.provider, ev.payload.status], [subject, 'anthropic', 'failing']);
++ let known = true;
++ try { contracts.resolve('ai.credential.failed@1'); } catch { known = false; }
++ if (known) valid(ev); // the schema arrives with openvibe-contracts 0.257.0
++ assert.ok(!JSON.stringify(ev).includes('sk-'), 'no key');
++});
++
t.test('close() stops the ai.run.* relay (unsent rows stay in the outbox)', async () => {
const events = require('../server/events');
const pending = (await events.status()).pending;
ddiff --git a/test/me-credentials.test.js b/test/me-credentials.test.js
nnew file mode 100644
iindex 0000000..09bd57f
---- /dev/null
++++ b/test/me-credentials.test.js
@@@ -0,0 +1,218 @@
++'use strict';
++// L36 Y1/Y3/Y7/Y8: the person's own provider keys and how their runs route.
++// - GET/PUT/DELETE/POST /api/v1/me/credentials and GET/PUT /api/v1/me/ai/preferences are person-only: a service
++// token (with or without on_behalf_of) is refused, so is another site's cookie session;
++// - a token minted for openvibe.network (every site's session) may read but never write; a write needs aud
++// openvibe.ai, and a cookie write needs Origin or Sec-Fetch-Site;
++// - a key is sealed with the AAD self|subject|provider and never returned, logged or put in an error;
++// - test on save lists models, falls back to a one-token request, records 401 and a timeout;
++// - a run on_behalf_of a person tries their self key first, only_my_keys never falls back, and the explain names
++// paid_by and every attempt.
++const assert = require('assert');
++const crypto = require('crypto');
++const { validate, schema } = require('openvibe-contracts');
++const HAS_ME_CONTRACT = (() => { try { return Boolean(schema('ai.me-credential@1')); } catch { return false; } })();
++const { boot, request, token, suite, ALL } = require('./helpers');
++const { createCredentials, aadFor, PROVIDERS, SELF_OWNER } = require('../server/credentials');
++
++const t = suite('me-credentials');
++const KEY = 'cd'.repeat(32);
++const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX';
++const API_KEY = 'sk-test-dana-0123456789WXYZ';
++const ANTHROPIC_KEY = 'sk-ant-test-0123456789ABCD';
++const person = token('dana', ALL, { sub: DANA, actorType: 'user' });
++const siteSession = token('dana-site', ALL, { sub: DANA, actorType: 'user', extra: { aud: ['openvibe.network', 'openvibe.design'] } });
++const service = token('live', [...ALL, 'ai.me.read', 'ai.me.manage']);
++const serviceOnBehalf = token('live', [...ALL, 'ai.me.read', 'ai.me.manage'], { extra: { on_behalf_of: DANA } });
++const calls = [];
++const logs = [];
++const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
++let mode = 'ok';
++let h;
++
++const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
++const credentialFetch = async (url, init = {}) => {
++ const auth = (init.headers && (init.headers.Authorization || init.headers['x-api-key'])) || null;
++ calls.push({ url, auth, body: init.body ? JSON.parse(init.body) : null });
++ if (mode === 'timeout') return await new Promise(() => {});
++ if (mode === 'auth') return json({ error: { message: `invalid key ${auth}` } }, 401);
++ if (String(url).endsWith('/models')) {
++ if (mode === 'empty') return json({ data: [] });
++ return json({ data: [{ id: 'm-one' }, { id: 'm-two' }, { id: 'm-three' }] });
++ }
++ if (String(url).endsWith('/messages')) {
++ return json({ model: 'm-one', content: [{ type: 'text', text: 'hello from dana\'s key' }], usage: { input_tokens: 4, output_tokens: 2 } });
++ }
++ if (String(url).endsWith('/chat/completions')) {
++ return json({ model: 'm-one', choices: [{ message: { role: 'assistant', content: 'hello from dana\'s key' } }], usage: { prompt_tokens: 4, completion_tokens: 2 } });
++ }
++ return json({});
++};
++
++t.test('boot', async () => {
++ h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_CREDENTIAL_TEST_TIMEOUT_MS: '80' }, credentialFetch, log: capture });
++});
++
++t.test('the person routes refuse a service token, an on_behalf_of token, and another site\'s session', async () => {
++ for (const tok of [service, serviceOnBehalf]) {
++ const r = await request(h.base, 'GET', '/api/v1/me/credentials', { tok });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], `${r.status} ${r.text}`);
++ }
++ // A person session cookie on AI's own site works, with the same-origin signal.
++ const raw = crypto.randomBytes(32).toString('base64url');
++ const hash = crypto.createHash('sha256').update(raw).digest('hex');
++ await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, 'dana', '2026-10-01', '2030-10-01')", [hash, DANA]);
++ const prefsBody = { only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } };
++ let r = await request(h.base, 'GET', '/api/v1/me/credentials', { headers: { Cookie: `ovai_person=${raw}` } });
++ assert.strictEqual(r.status, 200, 'a read with no Origin or Sec-Fetch-Site still works');
++ // A write with the cookie but neither header is not proof of same-origin: refused.
++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { headers: { Cookie: `ovai_person=${raw}` }, body: prefsBody });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'a write needs an origin signal');
++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { headers: { Cookie: `ovai_person=${raw}`, 'Sec-Fetch-Site': 'same-origin' }, body: prefsBody });
++ assert.strictEqual(r.status, 200, 'a same-origin cookie write works');
++ r = await request(h.base, 'GET', '/api/v1/me/credentials', { headers: { Cookie: `ovai_person=${raw}`, Origin: 'https://evil.example', 'Sec-Fetch-Site': 'cross-site' } });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], 'another site cannot spend the session');
++});
++
++t.test('a site session token may read but never write; a token for openvibe.ai may write', async () => {
++ let r = await request(h.base, 'GET', '/api/v1/me/credentials', { tok: siteSession });
++ assert.strictEqual(r.status, 200, 'a site token reads the hints');
++ r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: siteSession });
++ assert.strictEqual(r.status, 200, 'a site token reads the settings');
++ const prefsBody = { only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } };
++ const writes = [
++ ['PUT', '/api/v1/me/credentials/anthropic', { provider: 'anthropic', key: API_KEY }],
++ ['DELETE', '/api/v1/me/credentials/anthropic', undefined],
++ ['POST', '/api/v1/me/credentials/anthropic/test', undefined],
++ ['PUT', '/api/v1/me/ai/preferences', prefsBody],
++ ];
++ for (const [method, path, body] of writes) {
++ r = await request(h.base, method, path, { tok: siteSession, body });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.denied'], `${method} ${path} refused a site session token: ${r.text}`);
++ }
++ // A token whose aud explicitly names openvibe.ai reaches the writes.
++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: prefsBody });
++ assert.strictEqual(r.status, 200, r.text);
++});
++
++t.test('store a key: sealed with self|subject|provider, a four-character hint, never the key', async () => {
++ mode = 'ok';
++ let r = await request(h.base, 'PUT', `/api/v1/me/credentials/anthropic`, { tok: person, body: { provider: 'anthropic', key: ANTHROPIC_KEY, budget_usd_per_day: 4, models: { chat: 'm-one' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.provider, r.body.hint, r.body.status, r.body.models_count], ['anthropic', 'ABCD', 'ok', 3]);
++ if (HAS_ME_CONTRACT) assert.ok(validate('ai.me-credential@1', r.body).valid, JSON.stringify(validate('ai.me-credential@1', r.body).errors));
++ assert.ok(!r.text.includes(ANTHROPIC_KEY), 'the key never comes back');
++ const row = await h.db.prepare('SELECT key_enc, key_hint, owner FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'anthropic');
++ assert.strictEqual(row.owner, 'self');
++ assert.ok(row.key_enc.startsWith('v2:') && !row.key_enc.includes(ANTHROPIC_KEY), 'encrypted at rest');
++ assert.ok(!logs.join('\n').includes(ANTHROPIC_KEY), 'not in a log line');
++ const creds = createCredentials({ db: h.db, config: h.config });
++ const cred = await creds.forRun('self', DANA, 'anthropic');
++ assert.strictEqual(cred.apiKey, ANTHROPIC_KEY, 'opens with self|subject|provider');
++ assert.strictEqual(aadFor('self', DANA, 'anthropic'), `self|${DANA}|anthropic`);
++ // The AAD binds the provider: a row moved elsewhere does not decrypt.
++ await h.db.prepare('UPDATE subject_credentials SET provider = ? WHERE owner = ? AND subject = ? AND provider = ?').run('openai', SELF_OWNER, DANA, 'anthropic');
++ await assert.rejects(creds.forRun('self', DANA, 'openai'), /unable to authenticate|bad decrypt|Unsupported state/i);
++ await h.db.prepare('UPDATE subject_credentials SET provider = ? WHERE owner = ? AND subject = ? AND provider = ?').run('anthropic', SELF_OWNER, DANA, 'openai');
++});
++
++t.test('test on save: success, an empty model list, a 401 and a timeout', async () => {
++ // success already covered above (3 models). An empty list makes one one-token request.
++ mode = 'empty';
++ let r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: API_KEY, models: { chat: 'm-chat' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.status, r.body.models_count, r.body.last_error], ['ok', 0, null]);
++ assert.ok(calls.some((c) => c.url.endsWith('/chat/completions') && c.body.max_tokens === 1), 'one token');
++ // A 401 is failing, and the key is redacted from the error the provider echoed.
++ mode = 'auth';
++ r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: API_KEY } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.status, 'failing');
++ assert.ok(r.body.last_error && /refused/i.test(r.body.last_error), r.body.last_error);
++ assert.ok(!r.body.last_error.includes(API_KEY), 'the key is not in the stored error');
++ assert.ok(!r.text.includes(API_KEY), 'the key never comes back');
++ // A timeout is failing after the (test-shortened) 10 s deadline.
++ mode = 'timeout';
++ r = await request(h.base, 'PUT', '/api/v1/me/credentials/gemini', { tok: person, body: { provider: 'gemini', key: 'sk-test-gemini-0123456789' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.status, r.body.last_error], ['failing', 'no response within 0s']);
++ mode = 'ok';
++ // Every console provider is storable, not only today's list.
++ for (const provider of ['mistral', 'xai', 'groq', 'cerebras', 'together', 'fireworks']) {
++ assert.ok(PROVIDERS.includes(provider), `${provider} is a provider`);
++ }
++});
++
++t.test('a custom endpoint goes through the guarded fetcher\'s egress rules', async () => {
++ const real = createCredentials({ db: h.db, config: h.config }); // no test fetch: the production guarded fetcher
++ const out = await real.putSelf(DANA, 'custom', { provider: 'custom', key: 'sk-test-custom-0123456789', base_url: 'https://127.0.0.1/v1' });
++ assert.strictEqual(out.status, 'failing');
++ assert.ok(/IP-literal|non-public|refused|https/i.test(out.last_error), out.last_error);
++ await real.removeSelf(DANA, 'custom');
++});
++
++t.test('preferences are per person, with defaults and a model per kind', async () => {
++ let r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: person });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.only_my_keys, r.body.free_first, r.body.defaults.text], [false, true, 'auto']);
++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: { only_my_keys: true, free_first: false, price_ceiling_usd_per_mtok: 5, defaults: { text: 'm-default', code: 'auto', vision: 'auto', image: 'auto' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.only_my_keys, r.body.free_first, r.body.price_ceiling_usd_per_mtok, r.body.defaults.text], [true, false, 5, 'm-default']);
++ const other = token('bob', ALL, { sub: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPY', actorType: 'user' });
++ r = await request(h.base, 'GET', '/api/v1/me/ai/preferences', { tok: other });
++ assert.strictEqual(r.body.only_my_keys, false, 'preferences are per person');
++});
++
++t.test('a run on behalf of the person tries their key first; only_my_keys never falls back', async () => {
++ // Dana already has an anthropic key; give her an OpenAI key that works (empty list, one-token) and a stored model.
++ mode = 'ok';
++ let r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { provider: 'openai', key: API_KEY, models: { chat: 'm-chat' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ const run = async () => await request(h.base, 'POST', '/api/v1/runs?wait=8000', { tok: service, body: { workflow: 'live.viewers.line', input: { role: 'chat', user: 'say hi' }, on_behalf_of: { type: 'user', id: DANA } } });
++ r = await run();
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run).slice(0, 600));
++ assert.strictEqual(r.body.run.output.text, "hello from dana's key", JSON.stringify(r.body.run).slice(0, 1200));
++ assert.ok(r.body.run.explain && r.body.run.explain.paid_by === `your anthropic key …${ANTHROPIC_KEY.slice(-4)}`, JSON.stringify(r.body.run.explain));
++ assert.ok(r.body.run.explain.attempts.some((a) => a.provider === 'anthropic' && a.status === 'ok' && a.key === r.body.run.explain.paid_by));
++ // only_my_keys: a refused key is an explicit ai.needs_key, never a network fallback.
++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: { only_my_keys: true, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ mode = 'auth';
++ r = await run();
++ assert.strictEqual(r.body.run.status, 'failed', JSON.stringify(r.body.run).slice(0, 600));
++ assert.strictEqual(r.body.run.error.code, 'ai.needs_key');
++ assert.ok(/anthropic|openai/.test(String(r.body.run.error.detail)), `the error names the providers: ${r.body.run.error.detail}`);
++ assert.ok(!r.text.includes(API_KEY), 'the key is never in the answer');
++ // Without only_my_keys the network is the fallback, and explain says which key paid and what failed.
++ r = await request(h.base, 'PUT', '/api/v1/me/ai/preferences', { tok: person, body: { only_my_keys: false, free_first: true, price_ceiling_usd_per_mtok: null, defaults: { text: 'auto', code: 'auto', vision: 'auto', image: 'auto' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await run();
++ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run).slice(0, 600));
++ assert.strictEqual(r.body.run.explain.paid_by, 'openvibe');
++ assert.ok(r.body.run.explain.attempts.some((a) => a.key.startsWith('your ') && a.status === 'failed'));
++ const status = await h.db.prepare('SELECT status, last_error FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, DANA, 'openai');
++ assert.strictEqual(status.status, 'failing', 'a 401 marks the key failing');
++ assert.ok(status.last_error && !status.last_error.includes(API_KEY));
++ await request(h.base, 'DELETE', '/api/v1/me/credentials/openai', { tok: person });
++});
++
++t.test('a key never appears in a response, a log line or an error', async () => {
++ const IVAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPZ';
++ const ivan = token('ivan', ALL, { sub: IVAN, actorType: 'user' });
++ const key = 'sk-test-ivan-LEAKME-0123456789';
++ mode = 'auth';
++ const r = await request(h.base, 'PUT', `/api/v1/me/credentials/openai`, { tok: ivan, body: { provider: 'openai', key, models: { chat: 'm-chat' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(!r.text.includes(key), 'not in the response');
++ assert.ok(!logs.join('\n').includes(key), 'not in a log line');
++ assert.ok(!String(r.body.last_error || '').includes(key), 'not in the error');
++ const row = await h.db.prepare('SELECT last_error FROM subject_credentials WHERE owner = ? AND subject = ? AND provider = ?').get(SELF_OWNER, IVAN, 'openai');
++ assert.ok(!String(row.last_error).includes(key), 'not in the stored error');
++ mode = 'ok';
++ await request(h.base, 'DELETE', `/api/v1/me/credentials/openai`, { tok: ivan });
++});
++
++t.test('shutdown', async () => { await h.stop(); });
++
++t.run();
ddiff --git a/test/openapi.test.js b/test/openapi.test.js
iindex 3224dee..8f9f023 100644
---- a/test/openapi.test.js
++++ b/test/openapi.test.js
@@@ -44,13 +44,14 @@ function mounted(app) {
/**
* Not public API surface: probes and redirect, the operator/admin API, the attribution-quota API and
-- * the credential routes (which must never appear in a public document).
++ * the credential routes (which must never appear in a public document). The person's own /api/v1/me/*
++ * routes are person-only and carry a key on PUT, so they stay out for the same reason.
*/
const LEFT_OUT = [
'/api/health', '/api/ready', '/api/v1/openapi.json',
'/api/v1/status', '/api/v1/providers', '/api/v1/models', '/api/v1/routes', '/api/v1/templates', '/api/v1/workflows',
'/api/v1/quotas', '/api/v1/usage', '/api/v1/requests', '/api/v1/audit', '/api/v1/cache',
-- '/api/v1/credentials', '/api/v1/attribution-quotas',
++ '/api/v1/credentials', '/api/v1/attribution-quotas', '/api/v1/me',
];
const excluded = (key) => {
const p = key.split(' ')[1];