openvibers/openvibe.ai Commits

Console: the owner adds providers (DeepSeek, OpenRouter, Groq, Gemini, … or any OpenAI-compatible API) with their keys kept encrypted (vault:), replaces keys, registers models with typed-in prices, and chooses each route's provider and model (#151)

OP OpenVibers committed 19 days ago · 1ff766a930 · 11 files, +333 −18

Changes

ddiff --git a/README.md b/README.md
iindex 92b39e4..e1fdf4e 100644
---- a/README.md
++++ b/README.md
@@@ -254,7 +254,9 @@ needs scripts and inline styles), like Billing's staff console. Code: `server/co
| Page | What it shows |
|---|---|
| `/console` | providers needing attention, runs and failures of the last 24 h by error code, today's spend, the queue, the busiest quota windows |
--| `/console/providers` | every provider (kind, status, **secret reference name and whether it resolves, never a value**, circuit, base URL without query/user info, default model, priority/timeout) and the models |
++| `/console/providers` | every provider (kind, status, **secret reference name and whether it resolves, never a value**, circuit, base URL without query/user info, default model, priority/timeout) and the models; for the owner, per provider: replace its key, register a model (with prices typed from the provider's own price page, which the form requires) |
++| `/console/providers/new` | the owner adds a provider in a few clicks: DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API (`server/console/presets.js`), with its API key, which is kept encrypted (`vault:<provider>`, below) |
++| `/console/routes/:key` (owner form) | **use another provider**: a new route version whose primary is the provider and model chosen, with the previous primary kept as the first fallback; **back to the default** writes the newest seeded version again |
| `/console/routes`, `/templates`, `/workflows` (`/:key[?version=]`) | every key's newest version and the version in use; per key every version with its status and content (primary and fallbacks in order, prompts, steps, schemas) |
| `/console/runs` | runs newest first, filtered by status (tabs), workflow, requester (`live` = `service:live`), error code and a UTC time range; failed runs grouped by error code |
| `/console/runs/:id` | versions used, provider/model, fallback, attempts, tokens, cost, the Billing reading with its delivery state, free share and last send error, error code and detail, requester/attribution/target, grounding, citations, the request log (hashes only), and the stored input/output **bounded**: strings cut at 240 characters, lists at 20 items, 6 KB in all, credential-named keys blanked; raw debug fields never |
@@@ -271,7 +273,7 @@ the same one the admin API does:
|---|---|---|---|
| `staff.site.view` | admin, owner | `ai.usage.read` | open the console, read every page |
| `staff.ai.manage` | admin, owner | `ai.workflow.manage` | set a route/template/workflow version's status; cancel a queued or running run |
--| `staff.secrets.manage` | owner | `ai.provider.manage` | enable/disable a provider, reset its circuit, add/change a quota, purge the cache (one workflow, or all with a confirmation) |
++| `staff.secrets.manage` | owner | `ai.provider.manage` | add a provider with its key, replace a key, register a model, choose a route's provider and model, enable/disable a provider, reset its circuit, add/change a quota, purge the cache (one workflow, or all with a confirmation) |
A `global_mod` (only `staff.console.access`), a user or a token without a `usr_` subject is refused
(403, no session, audited as `console.sign_in.refused`). The session (`__Host-ovai_staff`, host-only,
@@@ -285,6 +287,15 @@ versions of routes, templates and workflows, models and provider records are sti
the admin API only. There is no drill mode in AI; the console starts nothing (no timer, no outbound
call) until someone signs in.
++### Provider keys added from the console (`vault:`)
++
++A provider the owner adds from the console keeps its key in `provider_secrets` (migration `0010_provider_secrets.sql`,
++`server/vault.js`): AES-256-GCM with `AI_CREDENTIALS_KEY`, bound to the provider's key so a row copied to another
++provider does not decrypt, with only a four-character hint ever shown. The provider's `secret_ref` is
++`vault:<provider key>`, which `util.resolveSecret` reads from this process's decrypted copy (loaded at boot and after
++every change; providers resolve secrets synchronously). Audit rows say a key was replaced, never what it is. Without
++`AI_CREDENTIALS_KEY` the form says keys cannot be stored and nothing is added.
++
## The eleven record groups
| Group | Table(s) |
ddiff --git a/migrations/0010_provider_secrets.sql b/migrations/0010_provider_secrets.sql
nnew file mode 100644
iindex 0000000..4a37abf
---- /dev/null
++++ b/migrations/0010_provider_secrets.sql
@@@ -0,0 +1,12 @@
++-- phase: expand
++-- Provider keys an owner adds from the operator console (server/vault.js): a provider whose secret_ref is
++-- 'vault:<provider key>' reads its key from here, AES-256-GCM encrypted with AI_CREDENTIALS_KEY and bound to the
++-- provider's key (the additional data), so a row copied to another provider does not decrypt. Only a hint (the last
++-- four characters) is ever shown back; the key never leaves the service.
++CREATE TABLE IF NOT EXISTS provider_secrets (
++ provider_key text COLLATE "C" PRIMARY KEY,
++ sealed text NOT NULL,
++ hint text COLLATE "C",
++ updated_by text COLLATE "C",
++ updated_at text COLLATE "C" NOT NULL
++);
ddiff --git a/server/app.js b/server/app.js
iindex 1844f2c..e5915d3 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -17,7 +17,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 }) {
++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 }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
@@@ -93,7 +93,7 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
if (credentials) app.use(require('./credentials').credentialsRouter({ credentials, auth, registry, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
app.use(adminRouter({ db, registry, pool, quotas, cache, runs, auth, ops, log }));
// The operator console (/console, /auth/*): Network staff, server-rendered, no scripts (server/console).
-- app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log }));
++ app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault }));
// Public price and latency (no sign-in, no scripts): provider_stats_daily totals and the rate cards,
// nothing per caller. Only registered providers are listed: a person's own key records its runs as
ddiff --git a/server/console/index.js b/server/console/index.js
iindex 09549f6..81b583a 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -77,6 +77,11 @@ function sanitizeNext(v) {
const NOTICES = {
provider_status: 'Provider status changed.',
++ provider_added: 'Provider added. Point a route at it below, or on the route\'s page.',
++ provider_key: 'Key replaced. Runs use it from now on.',
++ model_added: 'Model registered.',
++ route_used: 'Route changed: new runs use the provider and model you chose (the previous one is kept as a fallback).',
++ route_default: 'Route back to its default.',
circuit_reset: 'Circuit reset: the provider will be tried again.',
version_status: 'Version status changed.',
quota_saved: 'Quota saved.',
@@@ -90,7 +95,7 @@ const DEF_KINDS = {
};
const DAY_MS = 86400000;
--function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console }) {
++function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, vault = null }) {
const cc = config.console;
const r = express.Router();
const scoped = ['/console', '/auth'];
@@@ -268,7 +273,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
// ── Providers ────────────────────────────────────────────
r.get('/console/providers', read, wrap(async (req, res) => await send(res, 200, pages.providers({
...common(req), rows: (await Promise.all((await registry.listProviders()).map(ops.providerView))), models: await registry.listModels(), stubFallback: config.stubFallback,
-- canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req),
++ canManage: req.staff.caps.includes(CAPS.providerManage), notice: notice(req), keyHints: vault ? await vault.hints() : new Map(),
}))));
r.post('/console/providers/:key/status', needs(CAPS.providerManage), wrap(async (req, res) => {
await ops.setProviderStatus(req.params.key, String(req.body.status || ''), who(req));
@@@ -280,6 +285,84 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
res.redirect(303, '/console/providers?done=circuit_reset');
}));
++ // Adding a provider with its key, replacing a key, registering a model (owner: ai.provider.manage). The key goes to
++ // the vault (server/vault.js, encrypted at rest); the provider's secret_ref names it, never holds it.
++ const presets = require('./presets');
++ const KEY_RE = /^[a-z0-9][a-z0-9._-]{1,40}$/;
++ r.get('/console/providers/new', needs(CAPS.providerManage), wrap(async (req, res) => await send(res, 200, pages.providerNew({
++ ...common(req), presets: presets.PRESETS, preset: presets.byId(String(req.query.preset || '')) || null, vaultReady: !!vault && /^[0-9a-f]{64}$/i.test(String(config.credentialsKey || '')),
++ }))));
++ r.post('/console/providers', needs(CAPS.providerManage), wrap(async (req, res) => {
++ if (!vault) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');
++ const b = req.body || {};
++ const preset = presets.byId(String(b.preset || 'custom')) || presets.byId('custom');
++ const key = String(b.key || preset.id).trim().toLowerCase();
++ if (!KEY_RE.test(key)) throw new AiError(422, 'ai.invalid', 'the provider name is 2-41 lowercase letters, digits, dots and dashes');
++ if (await registry.getProvider(key)) throw new AiError(409, 'ai.conflict', `a provider called ${key} already exists: replace its key on the providers page`);
++ const baseUrl = String(b.base_url || preset.base_url || '').trim();
++ if (!/^https:\/\/[^\s]+$/i.test(baseUrl)) throw new AiError(422, 'ai.invalid', 'the base URL is an https address');
++ await vault.put(key, b.api_key, who(req));
++ const caps = [...presets.TEXT_CAPS, ...(b.vision ? ['vision'] : [])];
++ await registry.upsertProvider({
++ key, display_name: String(b.display_name || preset.name || key).slice(0, 80), kind: preset.kind, status: 'active', base_url: baseUrl.replace(/\/+$/, ''),
++ auth_mode: preset.kind === 'anthropic' ? 'x-api-key' : 'bearer', secret_ref: `vault:${key}`, default_model: String(b.default_model || preset.model || '').trim() || null,
++ capabilities: caps, timeout_ms: 60000, priority: 50, billing_profile: 'metered', metadata: { added_from: 'console', preset: preset.id },
++ }, { ...who(req), origin: 'admin' });
++ res.redirect(303, '/console/providers?done=provider_added');
++ }));
++ r.post('/console/providers/:key/key', needs(CAPS.providerManage), wrap(async (req, res) => {
++ if (!vault) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here');
++ const p = await registry.getProvider(req.params.key);
++ if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
++ await vault.put(p.key, (req.body || {}).api_key, who(req));
++ if (p.secret_ref !== `vault:${p.key}`) await registry.upsertProvider({ key: p.key, secret_ref: `vault:${p.key}` }, { ...who(req), origin: 'admin' });
++ else await registry.upsertProvider({ key: p.key }, { ...who(req), origin: 'admin' }); // a new updated_at: the pool rebuilds its adapter
++ await registry.audit(who(req).actor, 'provider.key_replaced', 'provider', p.key, { trace: who(req).trace });
++ res.redirect(303, '/console/providers?done=provider_key');
++ }));
++ r.post('/console/providers/:key/models', needs(CAPS.providerManage), wrap(async (req, res) => {
++ const p = await registry.getProvider(req.params.key);
++ if (!p) throw new AiError(404, 'ai.not_found', 'no such provider');
++ const b = req.body || {};
++ const price = (v) => (v === '' || v == null ? undefined : Number(v));
++ const source = String(b.price_source || '').trim();
++ if (source && !/^https:\/\/[^\s]+$/.test(source)) throw new AiError(422, 'ai.invalid', 'the price source is the provider\'s https price page');
++ if ((price(b.cost_in) != null || price(b.cost_out) != null) && !source) throw new AiError(422, 'ai.invalid', 'name the price page the prices come from');
++ await registry.upsertModel({
++ provider_key: p.key, model_key: String(b.model_key || '').trim(), display_name: String(b.display_name || '').trim() || null, type: 'chat',
++ supports: { json: true, vision: !!b.vision, tools: !!b.tools, streaming: true },
++ // Prices are the person's, typed from the provider's own price page (which they name), checked today.
++ ...(price(b.cost_in) != null || price(b.cost_out) != null ? { cost: { in_per_mtok: price(b.cost_in) ?? null, out_per_mtok: price(b.cost_out) ?? null, ...(source ? { source, verified_at: new Date(clock.now()).toISOString().slice(0, 10) } : {}) } } : {}),
++ }, who(req));
++ res.redirect(303, '/console/providers?done=model_added');
++ }));
++ // Which provider and model a route uses (owner: it changes what OpenVibe spends). A new route version with the
++ // choice as its primary and the route's previous primary as the first fallback; "default" goes back to what the
++ // service seeds (the newest version the seed wrote, as a new version).
++ r.post('/console/routes/:key/use', needs(CAPS.providerManage), wrap(async (req, res) => {
++ const route = await registry.getRoute(req.params.key);
++ if (!route) throw new AiError(404, 'ai.not_found', 'no such route');
++ const b = req.body || {};
++ if (b.default === '1') {
++ const seeded = (await registry.listRoutes({ history: true })).filter((x) => x.key === route.key && x.created_by === 'seed').sort((a, c) => c.version - a.version)[0];
++ if (!seeded) throw new AiError(409, 'ai.conflict', 'this route has no default to go back to');
++ const { version, created_by, created_at, key: _k, ...def } = seeded;
++ await registry.createRouteVersion(route.key, { ...def, status: 'active' }, who(req));
++ return res.redirect(303, `/console/routes/${encodeURIComponent(route.key)}?done=route_default`);
++ }
++ const [provider, model] = String(b.target || '').split('|');
++ const p = await registry.getProvider(provider || '');
++ if (!p) throw new AiError(422, 'ai.invalid', 'choose a provider');
++ const effective = route.alias_of ? await registry.resolveRoute(route.key) : route;
++ const previous = effective && effective.primary && effective.primary.provider && !(effective.primary.provider === p.key && (effective.primary.model || null) === (model || null)) ? [{ provider: effective.primary.provider, model: effective.primary.model || null }] : [];
++ await registry.createRouteVersion(route.key, {
++ primary: { provider: p.key, model: model || p.default_model || null }, fallbacks: previous, capability: null, constraints: {}, pinned: [], alias_of: null,
++ options: (effective && effective.options) || {}, max_output_tokens: effective ? effective.max_output_tokens : null,
++ response_format: (effective && effective.response_format) || 'text', timeout_ms: effective ? effective.timeout_ms : null, status: 'active',
++ }, who(req));
++ res.redirect(303, `/console/routes/${encodeURIComponent(route.key)}?done=route_used`);
++ }));
++
// ── Routes, templates, workflows ─────────────────────────
/** key -> the version runs use: the newest route version if active; the newest active template/workflow version. */
function inUseMap(kind, history) {
@@@ -305,6 +388,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
return await send(res, 200, pages.definition({
...common(req), kind, key: req.params.key, versions, selected: await registry[k.get](req.params.key, selected.version),
inUse: inUseMap(kind, versions).get(req.params.key), canManage: req.staff.caps.includes(CAPS.workflowManage), notice: notice(req),
++ ...(kind === 'routes' ? { canChoose: req.staff.caps.includes(CAPS.providerManage), providers: (await registry.listProviders()).filter((p) => p.status === 'active' && p.kind !== 'whisper' && p.kind !== 'stub'), models: await registry.listModels() } : {}),
}));
}));
r.post(`/console/${kind}/:key/versions/:version/status`, needs(CAPS.workflowManage), wrap(async (req, res) => {
ddiff --git a/server/console/presets.js b/server/console/presets.js
nnew file mode 100644
iindex 0000000..01f5f36
---- /dev/null
++++ b/server/console/presets.js
@@@ -0,0 +1,27 @@
++'use strict';
++/**
++ * Providers an owner can add from the console in a few clicks (server/console/index.js, /console/providers/new). Each
++ * is an OpenAI-compatible endpoint unless it says otherwise; prices are not here (rate cards are typed in by a person
++ * from the provider's own price page, with the page and the day, on the model form).
++ */
++const PRESETS = [
++ { id: 'deepseek', name: 'DeepSeek', kind: 'openai', base_url: 'https://api.deepseek.com/v1', model: 'deepseek-flash', keys: 'https://platform.deepseek.com/api_keys' },
++ { id: 'openrouter', name: 'OpenRouter', kind: 'openai', base_url: 'https://openrouter.ai/api/v1', model: 'openrouter/auto', keys: 'https://openrouter.ai/settings/keys' },
++ { id: 'groq', name: 'Groq', kind: 'openai', base_url: 'https://api.groq.com/openai/v1', model: 'openai/gpt-oss-20b', keys: 'https://console.groq.com/keys' },
++ { id: 'gemini', name: 'Google Gemini', kind: 'openai', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', model: '', keys: 'https://aistudio.google.com/apikey' },
++ { id: 'mistral', name: 'Mistral', kind: 'openai', base_url: 'https://api.mistral.ai/v1', model: '', keys: 'https://console.mistral.ai/api-keys' },
++ { id: 'together', name: 'Together AI', kind: 'openai', base_url: 'https://api.together.xyz/v1', model: '', keys: 'https://api.together.ai/settings/api-keys' },
++ { id: 'fireworks', name: 'Fireworks', kind: 'openai', base_url: 'https://api.fireworks.ai/inference/v1', model: '', keys: 'https://fireworks.ai/account/api-keys' },
++ { id: 'cerebras', name: 'Cerebras', kind: 'openai', base_url: 'https://api.cerebras.ai/v1', model: '', keys: 'https://cloud.cerebras.ai/' },
++ { id: 'xai', name: 'xAI', kind: 'openai', base_url: 'https://api.x.ai/v1', model: '', keys: 'https://console.x.ai/' },
++ { id: 'openai', name: 'OpenAI', kind: 'openai', base_url: 'https://api.openai.com/v1', model: 'gpt-5-mini', keys: 'https://platform.openai.com/api-keys' },
++ { id: 'anthropic', name: 'Anthropic', kind: 'anthropic', base_url: 'https://api.anthropic.com/v1', model: '', keys: 'https://console.anthropic.com/settings/keys' },
++ { id: 'custom', name: 'Another OpenAI-compatible API', kind: 'openai', base_url: '', model: '', keys: null },
++];
++
++// What a text provider added here can do (the router only offers it the operations it lists).
++const TEXT_CAPS = ['chat', 'generate', 'summarize', 'classify', 'extract', 'enrich', 'json'];
++
++const byId = (id) => PRESETS.find((p) => p.id === id) || null;
++
++module.exports = { PRESETS, TEXT_CAPS, byId };
ddiff --git a/server/console/views.js b/server/console/views.js
iindex b45d456..731c970 100644
---- a/server/console/views.js
++++ b/server/console/views.js
@@@ -60,6 +60,8 @@ form.inline select{width:auto}
button{font:inherit;padding:6px 13px;border-radius:7px;border:1px solid var(--line);background:var(--card);color:var(--fg);cursor:pointer}
button.primary{background:var(--accent-strong, var(--accent, #3472d8));border-color:var(--accent-strong, var(--accent, #3472d8));color:var(--on-accent-strong, var(--on-accent))}button.danger{border-color:var(--bad);color:var(--bad)}
button.link{border:0;background:none;padding:0;color:var(--accent);text-decoration:underline}
++a.button{display:inline-block;padding:6px 13px;border-radius:7px;background:var(--accent-strong, var(--accent, #3472d8));color:var(--on-accent-strong, var(--on-accent));text-decoration:none;font-weight:600}
++tr.sub td{border-top:0;padding-top:0}input.num{width:7em}tr.sub details{margin:2px 0 8px}tr.sub form{margin:6px 0}
fieldset{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:0;min-width:0}legend{padding:0 6px;font-weight:600}
dl{display:grid;grid-template-columns:minmax(0,max-content) minmax(0,1fr);gap:6px 16px;margin:0}dt{color:var(--muted)}dd{margin:0;min-width:0}
.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));gap:16px;align-items:start}
@@@ -204,19 +206,30 @@ function limitsUsed(c) {
return parts.join(' · ') || `${n(c.used.requests)} requests`;
}
--function providers({ staff, csrf, rows, models, canManage, stubFallback, notice, error }) {
++function providers({ staff, csrf, rows, models, canManage, stubFallback, notice, error, keyHints = new Map() }) {
++ const keyCell = (p) => {
++ const h = keyHints.get(p.key);
++ return h && String(p.secret_ref || '') === `vault:${p.key}` ? html`<br><span class="muted">key ${h.hint}, set ${when(h.updated_at)}</span>` : '';
++ };
++ const manageRow = (p) => (canManage && p.kind !== 'stub' && p.kind !== 'whisper' ? html`<tr class="sub"><td colspan="10"><details><summary>Key and models for <code>${p.key}</code></summary>
++<form class="inline" method="post" action="/console/providers/${enc(p.key)}/key">${csrfField(csrf)}<label>New API key <input type="password" name="api_key" required minlength="8" autocomplete="off" spellcheck="false"></label><button type="submit">Replace key</button></form>
++<form class="inline" method="post" action="/console/providers/${enc(p.key)}/models">${csrfField(csrf)}<label>Model <input type="text" name="model_key" required maxlength="200" placeholder="deepseek-flash" spellcheck="false"></label>
++<label><input type="checkbox" name="vision" value="1"> reads pictures</label> <label><input type="checkbox" name="tools" value="1"> tools</label>
++<label>$ in / Mtok <input type="number" name="cost_in" step="0.0001" min="0" class="num"></label> <label>$ out / Mtok <input type="number" name="cost_out" step="0.0001" min="0" class="num"></label>
++<label>from the price page <input type="url" name="price_source" placeholder="https://…/pricing"></label><button type="submit">Register model</button></form></details></td></tr>` : '');
return layout({
title: 'Providers', section: 'providers', staff, csrf, notice, error, body: html`<h1>Providers and models</h1>
--<p class="muted">Credentials are secret <em>references</em> (<code>env:NAME</code>) resolved from the service environment; this page shows the name and whether it resolves, never a value. Routes try their primary and then each fallback in order; the table below is in provider priority order. The deterministic stub joins every route as a last resort: <strong>${stubFallback ? 'on' : 'off'}</strong> (<code>AI_STUB_FALLBACK</code>).</p>
++${canManage ? html`<p><a class="button" href="/console/providers/new">Add a provider</a> <span class="muted">DeepSeek, OpenRouter, Groq, Gemini, Mistral, Together, Fireworks, Cerebras, xAI, OpenAI, Anthropic or any OpenAI-compatible API. Then choose it on a route's page.</span></p>` : ''}
++<p class="muted">Credentials are secret <em>references</em> (<code>env:NAME</code>, or <code>vault:NAME</code> for a key added here, kept encrypted) resolved inside the service; this page shows the name and whether it resolves, never a value. Routes try their primary and then each fallback in order; the table below is in provider priority order. The deterministic stub joins every route as a last resort: <strong>${stubFallback ? 'on' : 'off'}</strong> (<code>AI_STUB_FALLBACK</code>).</p>
${table('Providers', ['Provider', 'Kind', 'Status', 'Credentials', 'Circuit', 'Base URL', 'Default model', 'Can do', 'Priority / timeout', ...(canManage ? ['Actions'] : [])], rows.map((p) => html`<tr>
<td><code>${p.key}</code><br><span class="muted">${p.display_name}</span></td><td>${p.kind}</td><td>${badge(p.status)}</td>
--<td>${badge(p.credentials)}${p.secret_ref ? html`<br><code>${p.secret_ref}</code>` : ''}<br><span class="muted">${p.auth_mode}</span></td>
++<td>${badge(p.credentials)}${p.secret_ref ? html`<br><code>${p.secret_ref}</code>` : ''}${keyCell(p)}<br><span class="muted">${p.auth_mode}</span></td>
<td>${badge(p.health.state)}${p.health.consecutive_failures ? html`<br><span class="muted">${n(p.health.consecutive_failures)} failure(s) in a row</span>` : ''}${p.health.last_error ? html`<br><span class="muted">${clip(p.health.last_error, 160)}</span>` : ''}</td>
<td class="mono">${safeUrl(p.base_url)}</td><td>${p.default_model ? html`<code>${p.default_model}</code>` : '—'}</td><td>${(p.capabilities || []).join(', ') || '—'}</td>
<td>${n(p.priority)} · ${n(p.timeout_ms)} ms<br><span class="muted">${p.origin}, updated ${when(p.updated_at)}</span></td>
${canManage ? html`<td><form class="inline" method="post" action="/console/providers/${enc(p.key)}/status">${csrfField(csrf)}<input type="hidden" name="status" value="${p.status === 'active' ? 'disabled' : 'active'}"><button type="submit" class="${p.status === 'active' ? 'danger' : ''}">${p.status === 'active' ? 'Disable' : 'Enable'}<span class="sr-only"> ${p.key}</span></button></form>
${p.health.state !== 'closed' || p.health.consecutive_failures ? html` <form class="inline" method="post" action="/console/providers/${enc(p.key)}/reset">${csrfField(csrf)}<button type="submit">Reset circuit<span class="sr-only"> of ${p.key}</span></button></form>` : ''}</td>` : ''}
--</tr>`), 'no providers.')}
++</tr>${manageRow(p)}`), 'no providers.')}
${canManage ? '' : html`<p class="muted">Enabling, disabling and circuit resets need <code>ai.provider.manage</code> (the owner's <code>staff.secrets.manage</code>).</p>`}
${table('Models', ['Provider', 'Model', 'Type', 'Status', 'Context / max output', 'Cost per Mtok (in / out / cached)', 'Supports'], models.map((m) => html`<tr>
<td><code>${m.provider_key}</code></td><td><code>${m.model_key}</code></td><td>${m.type}</td><td>${badge(m.status)}</td>
@@@ -226,6 +239,24 @@ ${table('Models', ['Provider', 'Model', 'Type', 'Status', 'Context / max output'
});
}
++function providerNew({ staff, csrf, presets, preset, vaultReady, notice, error }) {
++ const p = preset || presets.find((x) => x.id === 'deepseek');
++ return layout({
++ title: 'Add a provider', section: 'providers', staff, csrf, notice, error, body: html`<h1>Add a provider</h1>
++<p class="muted">Pick a provider, paste an API key from its site and name its default model. The key is stored encrypted inside OpenVibe.AI and never shown again (only its last four characters). Nothing uses the provider until you choose it on a route's page.</p>
++${vaultReady ? '' : html`<p class="bad">Keys cannot be stored on this server: <code>AI_CREDENTIALS_KEY</code> is not set.</p>`}
++<nav aria-label="Providers"><ul class="tabs">${presets.map((x) => html`<li><a href="/console/providers/new?preset=${enc(x.id)}"${x.id === p.id ? html` aria-current="page"` : ''}>${x.name}</a></li>`)}</ul></nav>
++<form method="post" action="/console/providers" class="stack">${csrfField(csrf)}<input type="hidden" name="preset" value="${p.id}">
++<label>Name in OpenVibe <input type="text" name="key" required pattern="[a-z0-9][a-z0-9._-]{1,40}" value="${p.id === 'custom' ? '' : p.id}" spellcheck="false"></label>
++<label>Shown as <input type="text" name="display_name" maxlength="80" value="${p.id === 'custom' ? '' : p.name}"></label>
++<label>Base URL <input type="url" name="base_url" required value="${p.base_url}" placeholder="https://api.example.com/v1" spellcheck="false"></label>
++<label>API key <input type="password" name="api_key" required minlength="8" autocomplete="off" spellcheck="false"></label>${p.keys ? html` <span class="muted">Get one at <a href="${p.keys}" rel="noopener noreferrer">${safeUrl(p.keys)}</a></span>` : ''}
++<label>Default model <input type="text" name="default_model" value="${p.model}" placeholder="the model id the provider uses" spellcheck="false"></label>
++<label><input type="checkbox" name="vision" value="1"> Its default model reads pictures</label>
++<p><button type="submit"${vaultReady ? '' : html` disabled`}>Add ${p.id === 'custom' ? 'the provider' : p.name}</button> <a href="/console/providers">Back</a></p></form>`,
++ });
++}
++
const KINDS = {
routes: { one: 'route', title: 'Routes', statuses: ['active', 'disabled'] },
templates: { one: 'template', title: 'Templates', statuses: ['draft', 'active', 'deprecated', 'archived'] },
@@@ -263,7 +294,7 @@ ${table(`${k.title} (newest version of each)`, head, body, `no ${kind}.`)}`,
});
}
--function definition({ staff, csrf, kind, key, versions, selected, inUse, canManage, notice, error }) {
++function definition({ staff, csrf, kind, key, versions, selected, inUse, canManage, notice, error, canChoose = false, providers = [], models = [] }) {
const k = KINDS[kind];
const v = selected;
let content;
@@@ -303,6 +334,13 @@ ${table('Versions', ['Version', 'Status', 'Created by', 'Created', ...(canManage
<td><a href="/console/${kind}/${enc(key)}${qs({ version: x.version })}"${x.version === v.version ? html` aria-current="true"` : ''}>v${x.version}</a>${x.version === inUse ? html` <span class="ok">in use</span>` : ''}</td>
<td>${badge(x.status)}</td><td>${x.created_by || '—'}</td><td>${when(x.created_at)}</td>${canManage ? html`<td>${statusForm(x)}</td>` : ''}</tr>`))}
${canManage ? '' : html`<p class="muted">Changing a version's status needs <code>ai.workflow.manage</code> (<code>staff.ai.manage</code>).</p>`}
++${kind === 'routes' && canChoose ? html`<h2>Use another provider</h2>
++<form class="inline" method="post" action="/console/routes/${enc(key)}/use">${csrfField(csrf)}<label>Provider and model <select name="target">${providers.map((p) => [
++ html`<option value="${p.key}|">${p.display_name || p.key} · ${p.default_model || 'its default model'}</option>`,
++ ...models.filter((m) => m.provider_key === p.key && m.status === 'active' && m.model_key !== p.default_model).map((m) => html`<option value="${p.key}|${m.model_key}">${p.display_name || p.key} · ${m.model_key}</option>`),
++ ])}</select></label><button type="submit">Use for this route</button></form>
++<form class="inline" method="post" action="/console/routes/${enc(key)}/use">${csrfField(csrf)}<input type="hidden" name="default" value="1"><button type="submit">Back to the default</button></form>
++<p class="muted">A new version of the route: runs use the provider and model you choose, with the route's previous primary as its first fallback. Every change is audited.</p>` : ''}
<h2>Version ${v.version}</h2>${content}`,
});
}
@@@ -533,4 +571,4 @@ ${table(`Providers and models, ${from} to ${to}`, ['Provider', 'Model', 'Capabil
});
}
--module.exports = { CSP, html, esc, safeUrl, pages: { signIn, message, overview, providers, definitions, definition, runs, run, quotas, usage, cache, audit, stats } };
++module.exports = { CSP, html, esc, safeUrl, pages: { signIn, message, overview, providers, providerNew, definitions, definition, runs, run, quotas, usage, cache, audit, stats } };
ddiff --git a/server/index.js b/server/index.js
iindex d28198e..ead9cb8 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -32,6 +32,9 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
const registry = createRegistry(db, { clock, env });
++ // Provider keys an owner stored from the console (server/vault.js): into this process before any provider is built.
++ const vault = require('./vault').createVault({ db, config, clock, log });
++ await vault.load().catch((err) => log.warn(`[Vault] ${err.message}`));
const pool = createProviderPool({ db, registry, config, clock, fetchImpl, env, log, credentialFetch });
// T6 step 8: the SaaS tier budgets on openvibe-sdk/govern in Valkey (server/govern.js); off unless AI_GOVERN_TIERS=1.
const tiers = createTiers({ config, valkey, clock, log });
@@@ -62,7 +65,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// NODE_ENV=test (tests pin a PEM or point the URL at a stub and let the client fetch on first use).
const keys = createNetworkKeys({ config });
const auth = createAuth({ config, log });
-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials });
++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, vault });
let jwks = null;
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
ddiff --git a/server/registry.js b/server/registry.js
iindex a9f020f..f59d7ec 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -66,7 +66,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
if (!PROVIDER_KINDS.includes(p.kind)) throw new AiError(422, 'ai.invalid', `provider kind must be one of ${PROVIDER_KINDS.join(', ')}`);
if (!['active', 'disabled'].includes(p.status)) throw new AiError(422, 'ai.invalid', 'provider status must be active or disabled');
if (!['none', 'bearer', 'x-api-key'].includes(p.auth_mode)) throw new AiError(422, 'ai.invalid', 'auth_mode must be none, bearer or x-api-key');
-- if (!secretRefValid(p.secret_ref)) throw new AiError(422, 'ai.invalid', "secret_ref must be 'env:NAME' (a reference, never a secret value)");
++ if (!secretRefValid(p.secret_ref)) throw new AiError(422, 'ai.invalid', "secret_ref must be 'env:NAME' or 'vault:<provider key>' (a reference, never a secret value)");
if (p.base_url && !/^https?:\/\/[^\s]+$/i.test(p.base_url)) throw new AiError(422, 'ai.invalid', 'base_url must be an http(s) URL');
if (!BILLING_PROFILES.includes(p.billing_profile)) throw new AiError(422, 'ai.invalid', `billing_profile must be one of ${BILLING_PROFILES.join(', ')}`);
// metadata.billing_profile only seeds the column (upsertProvider), but it is refused here too so an
ddiff --git a/server/util.js b/server/util.js
iindex 49654ce..69709fe 100644
---- a/server/util.js
++++ b/server/util.js
@@@ -42,16 +42,21 @@ function sendError(res, err, ctx, log = console) {
return svc.sendError(res, req, err, log, PROBLEMS);
}
--/** 'env:NAME' -> the value of process.env.NAME (read by name; never logged). */
++/**
++ * A secret by reference, never logged: 'env:NAME' → the value of process.env.NAME; 'vault:<provider key>' → the key an
++ * owner stored for that provider from the console (server/vault.js, encrypted at rest).
++ */
function resolveSecret(ref, env = process.env) {
if (!ref) return '';
++ const v = /^vault:([a-z0-9][a-z0-9._-]{0,63})$/.exec(String(ref));
++ if (v) return require('./vault').get(v[1]);
const m = /^env:([A-Z][A-Z0-9_]{0,63})$/.exec(String(ref));
if (!m) return '';
return env[m[1]] ? String(env[m[1]]) : '';
}
function secretRefValid(ref) {
-- return ref == null || ref === '' || /^env:[A-Z][A-Z0-9_]{0,63}$/.test(String(ref));
++ return ref == null || ref === '' || /^env:[A-Z][A-Z0-9_]{0,63}$/.test(String(ref)) || /^vault:[a-z0-9][a-z0-9._-]{0,63}$/.test(String(ref));
}
/** Loose JSON repair, ported from Live's llm.parseJsonLoose (models emit slightly broken JSON). */
ddiff --git a/server/vault.js b/server/vault.js
nnew file mode 100644
iindex 0000000..c96fb0d
---- /dev/null
++++ b/server/vault.js
@@@ -0,0 +1,79 @@
++'use strict';
++/**
++ * Provider keys an owner adds from the operator console (migrations/0010_provider_secrets.sql). A provider whose
++ * secret_ref is 'vault:<provider key>' (util.resolveSecret) reads its key from here.
++ *
++ * At rest each key is AES-256-GCM encrypted with AI_CREDENTIALS_KEY, bound to its provider key (the additional data).
++ * The decrypted keys are held in this process only (load() at boot and after every change), because the provider pool
++ * resolves secrets synchronously; nothing here logs, returns or audits a value — only a four-character hint.
++ */
++const crypto = require('crypto');
++const { AiError } = require('./util');
++
++const KEY_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
++const keys = new Map(); // provider key → plaintext, this process only
++
++function secretKey(config) {
++ const hex = String((config && config.credentialsKey) || '');
++ if (!/^[0-9a-f]{64}$/i.test(hex)) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored here: AI_CREDENTIALS_KEY is not set');
++ return Buffer.from(hex, 'hex');
++}
++
++function seal(config, plain, aad) {
++ const iv = crypto.randomBytes(12);
++ const c = crypto.createCipheriv('aes-256-gcm', secretKey(config), iv);
++ c.setAAD(Buffer.from(`provider:${aad}`));
++ const body = Buffer.concat([c.update(String(plain), 'utf8'), c.final()]);
++ return `v1:${iv.toString('base64')}:${c.getAuthTag().toString('base64')}:${body.toString('base64')}`;
++}
++
++function open(config, sealed, aad) {
++ const [v, iv, tag, body] = String(sealed || '').split(':');
++ if (v !== 'v1' || !iv || !tag || !body) throw new Error('not a sealed key');
++ const d = crypto.createDecipheriv('aes-256-gcm', secretKey(config), Buffer.from(iv, 'base64'));
++ d.setAAD(Buffer.from(`provider:${aad}`));
++ d.setAuthTag(Buffer.from(tag, 'base64'));
++ return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8');
++}
++
++function createVault({ db, config, clock = { now: () => Date.now() }, log = console }) {
++ /** Read every stored key into this process (a key that no longer decrypts is skipped and named in the log). */
++ async function load() {
++ const rows = await db.prepare('SELECT provider_key, sealed FROM provider_secrets').all();
++ keys.clear();
++ for (const r of rows) {
++ try { keys.set(r.provider_key, open(config, r.sealed, r.provider_key)); } catch { log.warn(`[Vault] the key of provider ${r.provider_key} does not decrypt (was AI_CREDENTIALS_KEY changed?)`); }
++ }
++ return keys.size;
++ }
++
++ async function put(providerKey, plain, { actor = 'system' } = {}) {
++ if (!KEY_RE.test(providerKey || '')) throw new AiError(422, 'ai.invalid', 'provider key must be lowercase dotted/dashed');
++ const value = String(plain || '').trim();
++ if (value.length < 8 || value.length > 4000 || /\s/.test(value)) throw new AiError(422, 'ai.invalid', 'that does not look like an API key');
++ const sealed = seal(config, value, providerKey);
++ const t = new Date(clock.now()).toISOString();
++ await db.prepare(`INSERT INTO provider_secrets (provider_key, sealed, hint, updated_by, updated_at) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT (provider_key) DO UPDATE SET sealed = excluded.sealed, hint = excluded.hint, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
++ .run(providerKey, sealed, `…${value.slice(-4)}`, actor, t);
++ keys.set(providerKey, value);
++ return { provider_key: providerKey, hint: `…${value.slice(-4)}`, updated_at: t };
++ }
++
++ async function remove(providerKey) {
++ await db.prepare('DELETE FROM provider_secrets WHERE provider_key = ?').run(providerKey);
++ keys.delete(providerKey);
++ }
++
++ async function hints() {
++ const rows = await db.prepare('SELECT provider_key, hint, updated_by, updated_at FROM provider_secrets').all();
++ return new Map(rows.map((r) => [r.provider_key, { hint: r.hint, updated_by: r.updated_by, updated_at: r.updated_at }]));
++ }
++
++ return { load, put, remove, hints };
++}
++
++/** The key of a provider whose secret_ref is 'vault:<key>' ('' when none is stored). */
++function get(providerKey) { return keys.get(providerKey) || ''; }
++
++module.exports = { createVault, get, KEY_RE, _seal: seal, _open: open, _keys: keys };
ddiff --git a/test/console.test.js b/test/console.test.js
iindex 313b85a..6d208a3 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -23,7 +23,10 @@ const NETWORK = 'http://network.test';
const PROVIDER_SECRET = `sentinel-provider-${crypto.randomBytes(12).toString('hex')}`;
const CLIENT_SECRET = `sentinel-client-${crypto.randomBytes(12).toString('hex')}`;
const SESSION_SECRET = crypto.randomBytes(32).toString('hex');
--const SECRETS = [PROVIDER_SECRET, CLIENT_SECRET, SESSION_SECRET];
++const VAULT_KEY = crypto.randomBytes(32).toString('hex');
++const ADDED_KEY = `sentinel-added-${crypto.randomBytes(12).toString('hex')}`;
++const REPLACED_KEY = `sentinel-replaced-${crypto.randomBytes(12).toString('hex')}`;
++const SECRETS = [PROVIDER_SECRET, CLIENT_SECRET, SESSION_SECRET, VAULT_KEY, ADDED_KEY, REPLACED_KEY];
const ops = token('ops', ALL);
const live = token('live', ['ai.run.create', 'ai.run.read']);
const news = token('news', ['ai.run.create', 'ai.run.read']);
@@@ -133,7 +136,7 @@ t.test('boot with a provider secret, a seam provider, a failing stub and a few r
h = await boot({
env: {
BASE_URL: 'https://ai.test', OV_NETWORK_INTERNAL_URL: NETWORK, OV_OAUTH_CLIENT_SECRET: CLIENT_SECRET, AI_CONSOLE_SESSION_SECRET: SESSION_SECRET,
-- AI_USER_MODULES: 'off', AI_API_KEY: PROVIDER_SECRET, AI_PROVIDER: 'openai', AI_BASE_URL: 'http://127.0.0.1:1/v1', AI_STUB_FALLBACK: 'false',
++ AI_USER_MODULES: 'off', AI_API_KEY: PROVIDER_SECRET, AI_PROVIDER: 'openai', AI_BASE_URL: 'http://127.0.0.1:1/v1', AI_STUB_FALLBACK: 'false', AI_CREDENTIALS_KEY: VAULT_KEY,
},
fetchImpl: fakeFetch,
});
@@@ -365,6 +368,59 @@ t.test('owner: providers, circuits, quotas and cache purge through the console,
assert.strictEqual(JSON.parse(purge.metadata).removed, 1);
});
++t.test('owner: add a provider with its key (kept encrypted), replace the key, register a model, point a route at it and back', async () => {
++ let x = await get('/console/providers/new?preset=deepseek', cookies.owner);
++ assert.strictEqual(x.status, 200);
++ assert.match(x.text, /value="https:\/\/api\.deepseek\.com\/v1"/);
++ assert.match(x.text, /name="api_key" required/);
++ assert.strictEqual((await get('/console/providers/new', cookies.admin)).status, 403, 'an admin does not add providers');
++ x = await post('/console/providers', cookies.owner, { preset: 'custom', key: 'cheapapi', display_name: 'Cheap API', base_url: 'http://insecure.test/v1', api_key: ADDED_KEY, default_model: 'cheap-1', _csrf: csrf.owner });
++ assert.ok(x.status >= 400, 'https only');
++ x = await post('/console/providers', cookies.owner, { preset: 'custom', key: 'cheapapi', display_name: 'Cheap API', base_url: 'https://api.cheap.test/v1/', api_key: ADDED_KEY, default_model: 'cheap-1', _csrf: csrf.owner });
++ assert.strictEqual(x.status, 303, x.text);
++ const p = await h.registry.getProvider('cheapapi');
++ assert.deepStrictEqual([p.kind, p.secret_ref, p.base_url, p.default_model, p.auth_mode], ['openai', 'vault:cheapapi', 'https://api.cheap.test/v1', 'cheap-1', 'bearer']);
++ const row = await h.db.prepare('SELECT sealed, hint FROM provider_secrets WHERE provider_key = ?').get('cheapapi');
++ assert.ok(row && !row.sealed.includes(ADDED_KEY), 'encrypted at rest');
++ assert.strictEqual(row.hint, `…${ADDED_KEY.slice(-4)}`);
++ assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), ADDED_KEY, 'the pool resolves it');
++ assert.strictEqual(require('../server/vault')._open({ credentialsKey: VAULT_KEY }, row.sealed, 'cheapapi'), ADDED_KEY);
++ assert.throws(() => require('../server/vault')._open({ credentialsKey: VAULT_KEY }, row.sealed, 'otherapi'), 'bound to its provider');
++ x = await get('/console/providers', cookies.owner);
++ assert.ok(!x.text.includes(ADDED_KEY), 'the page never shows the key');
++ assert.match(x.text, new RegExp(`key …${ADDED_KEY.slice(-4)}`));
++ x = await post('/console/providers', cookies.owner, { preset: 'custom', key: 'cheapapi', base_url: 'https://api.cheap.test/v1', api_key: ADDED_KEY, _csrf: csrf.owner });
++ assert.ok(x.status >= 400, 'no second provider of the same name');
++ x = await post('/console/providers/cheapapi/key', cookies.owner, { api_key: REPLACED_KEY, _csrf: csrf.owner });
++ assert.strictEqual(x.status, 303);
++ assert.strictEqual(require('../server/util').resolveSecret('vault:cheapapi'), REPLACED_KEY);
++ x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', _csrf: csrf.owner });
++ assert.ok(x.status >= 400, 'prices name the page they come from');
++ x = await post('/console/providers/cheapapi/models', cookies.owner, { model_key: 'cheap-2', cost_in: '0.1', cost_out: '0.4', price_source: 'https://cheap.test/pricing', tools: '1', _csrf: csrf.owner });
++ assert.strictEqual(x.status, 303, x.text);
++ const m = await h.registry.getModel('cheapapi', 'cheap-2');
++ assert.deepStrictEqual([m.cost.in_per_mtok, m.cost.out_per_mtok, m.supports.tools], [0.1, 0.4, true]);
++ // Point default.json at it: a new version, the previous primary kept as the first fallback; then back to the default.
++ const before = await h.registry.getRoute('default.json');
++ x = await get('/console/routes/default.json', cookies.owner);
++ assert.match(x.text, /<option value="cheapapi\|cheap-2">Cheap API · cheap-2<\/option>/);
++ x = await post('/console/routes/default.json/use', cookies.owner, { target: 'cheapapi|cheap-2', _csrf: csrf.owner });
++ assert.strictEqual(x.status, 303, x.text);
++ const used = await h.registry.getRoute('default.json');
++ assert.strictEqual(used.version, before.version + 1);
++ assert.deepStrictEqual(used.primary, { provider: 'cheapapi', model: 'cheap-2' });
++ assert.strictEqual(used.created_by, people.owner);
++ x = await post('/console/routes/default.json/use', cookies.owner, { default: '1', _csrf: csrf.owner });
++ assert.strictEqual(x.status, 303, x.text);
++ const back = await h.registry.getRoute('default.json');
++ assert.strictEqual(back.version, used.version + 1);
++ assert.notStrictEqual((back.primary || {}).provider, 'cheapapi');
++ assert.strictEqual((await post('/console/routes/default.json/use', cookies.admin, { target: 'cheapapi|', _csrf: csrf.admin })).status, 403, 'only the owner changes what a route spends');
++ const audits = await auditRows("target_id = 'cheapapi'");
++ assert.ok(audits.some((a) => a.action === 'provider.create') && audits.some((a) => a.action === 'provider.key_replaced'));
++ assert.ok(audits.every((a) => !String(a.metadata).includes(ADDED_KEY) && !String(a.metadata).includes(REPLACED_KEY)), 'no key in the audit');
++});
++
t.test('admin API changes write the same audit rows, and the audit page shows who changed what', async () => {
assert.strictEqual((await request(h.base, 'POST', '/api/v1/providers/badstub/enable', { tok: ops })).status, 200);
assert.strictEqual((await request(h.base, 'POST', '/api/v1/quotas', { tok: ops, body: { scope_type: 'global', window: 'hour', max_cost_usd: 10 } })).status, 201);