openvibers/openvibe.ai Commits

Add owner provider key status, checks and safe removal (#434)

OP OpenVibers committed 3 days ago · c3e03ae517 · 8 files, +297 −7

* Add secret-safe owner provider key checks and removal

* Preserve provider configuration ownership during checks

Changes

ddiff --git a/docs/provider-registry.md b/docs/provider-registry.md
iindex d729e55..beeaed0 100644
---- a/docs/provider-registry.md
++++ b/docs/provider-registry.md
@@@ -52,3 +52,18 @@ Required-provider documentation was checked on 2026-11-10. Meta now documents Mo
Search capabilities use web-search and image-search, matching the shared placement capability syntax. Brave web and images share one provider/key, as do Serper web and images. Openverse and Wikimedia Commons require no key. SearXNG has no invented public address; the owner supplies their self-hosted base.
For Fable: the existing providers console now receives all seeded provider rows and protocol metadata. Its visual grouping and capability presentation remain for the main session.
++
++Owner key controls use the existing encrypted `ai.provider_keys` store. The console JSON exposes only presence,
++the last four characters and the stored date. Saving or replacing a key makes no provider request. Deleting a key
++disables the provider and clears its secret reference before removing the encrypted value; it does not fall back
++to an environment key. The separate Setup panel retains its existing environment-fallback behavior.
++
++`POST /api/v1/admin/console/providers/:key/check` performs at most one request and never enables the provider.
++It uses the model-list endpoint, a minimal search for search-only adapters, or a registered read-only account
++endpoint: [OpenRouter's current key](https://openrouter.ai/docs/api/api-reference/api-keys/get-current-api-key)
++and [Tavily usage](https://docs.tavily.com/documentation/api-reference/endpoint/usage). There are no retries,
++redirects, generated tokens or automatic checks when a key is saved. Only HTTP status, a safe error code,
++latency and check time are stored; no upstream response text is logged or returned. A model-list check confirms
++endpoint access, not that a particular model can run. Missing keys result in zero requests.
++
++For Fable: key marker/date, explicit check button, check status/latency and remove-and-disable controls in each provider.
ddiff --git a/server/api/console-data.js b/server/api/console-data.js
iindex dd491f4..fec564e 100644
---- a/server/api/console-data.js
++++ b/server/api/console-data.js
@@@ -3,7 +3,7 @@
const express = require('express');
const q = require('../console/queries');
const { CAPS } = require('../auth');
--const { AiError } = require('../util');
++const { AiError, providerSecret } = require('../util');
const providerPresets = require('../console/presets');
const providerCatalog = require('../providers/catalog');
const poolSeed = require('../providers/pool-seed');
@@@ -11,7 +11,7 @@ const gpuCatalog = require('../gpu/catalog');
const { tokenCosts } = require('../gpu/token-cost');
/** Read-only JSON read models for Exchange's owner console. Every query is bounded and the query module redacts runs. */
--function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, gpus = null, gpuMarket = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
++function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, gpus = null, gpuMarket = null, abuse = null, priceBook = null, creditGrants = null, sessionCookies = null, vault = null, settings = null, config = {}, log = console }) {
const r = express.Router();
const supply = require('../platform/supply').createSupply({ db, registry, clock });
// The public marketplace readings and the per-token cost they imply, added to both console sections. Data only:
@@@ -41,8 +41,14 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
return { status: await ops.status(), counts24: await q.statusCounts(db, since), failures24: await q.failures(db, since), queue: runs.stats(), cache: await cache.stats() };
}
if (section === 'providers') {
++ const hints = vault && vault.hints ? await vault.hints() : new Map();
const rows = (await Promise.all((await registry.listProviders()).map(ops.providerView))).map((p) => ({
...p, key_present: p.credentials === 'configured', preference_order: p.priority,
++ key_state: p.auth_mode === 'none' ? { source: 'not_required', hint: null, set_at: null }
++ : { source: hints.has(p.key) ? 'stored' : p.credentials === 'configured' ? 'environment' : 'missing',
++ hint: hints.get(p.key)?.hint || (providerSecret(p, env) ? `…${providerSecret(p, env).slice(-4)}` : null),
++ set_at: hints.get(p.key)?.updated_at || null },
++ last_check: p.metadata?.last_check || null,
markup_percent: Number(p.metadata && p.metadata.markup_percent) || 0,
uncensored: Boolean(p.metadata && p.metadata.uncensored),
}));
@@@ -106,10 +112,33 @@ function consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth,
if (value.length < 8 || value.length > 400) throw new AiError(422, 'ai.invalid', 'the provider key must be 8-400 characters');
const who = { actor: actor(req), trace: req.ov && req.ov.traceId };
await vault.put(provider.key, value, who);
-- await registry.upsertProvider({ key: provider.key, secret_ref: `vault:${provider.key}` }, { ...who, origin: 'admin' });
++ await registry.upsertProvider({ key: provider.key, secret_ref: `vault:${provider.key}`, metadata: { ...provider.metadata, last_check: null } }, { ...who, origin: 'admin' });
await registry.audit(who.actor, 'provider.key_replaced', 'provider', provider.key, { trace: who.trace });
return { ok: true, provider: provider.key };
}));
++ r.delete('/api/v1/admin/console/providers/:key/key', write, wrap(async (req) => {
++ if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
++ const provider = await registry.getProvider(req.params.key);
++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
++ const who = { actor: actor(req), trace: req.ov && req.ov.traceId };
++ // Disable first: a request racing the store deletion must not fall back to an environment key.
++ await registry.upsertProvider({ key: provider.key, status: 'disabled', secret_ref: null,
++ metadata: { ...provider.metadata, last_check: null } }, { ...who, origin: 'admin' });
++ await vault.remove(provider.key);
++ await registry.audit(who.actor, 'provider.key_removed', 'provider', provider.key, { trace: who.trace });
++ return { ok: true, provider: provider.key };
++ }));
++ r.post('/api/v1/admin/console/providers/:key/check', write, wrap(async (req) => {
++ const provider = await registry.getProvider(req.params.key);
++ if (!provider) throw new AiError(404, 'ai.not_found', 'no such provider');
++ const { checkProvider } = require('../providers/check');
++ const result = await checkProvider(provider, { env, now: clock.now,
++ fetchImpl: fetchImpl === globalThis.fetch ? require('../providers/guarded-fetch').guardedFetch : fetchImpl });
++ // A check does not enable a provider or overwrite settings changed while its request was in flight.
++ const current = await registry.getProvider(provider.key);
++ if (current.updated_at === provider.updated_at) await registry.upsertProvider({ key: provider.key, metadata: { ...current.metadata, last_check: result } }, { actor: actor(req), origin: current.origin });
++ return { provider: provider.key, ...result };
++ }));
r.post('/api/v1/admin/console/providers', json, write, wrap(async (req) => {
if (!vault || !settings) throw new AiError(503, 'vault.unavailable', 'provider keys cannot be stored on this host');
const b = req.body || {};
ddiff --git a/server/app.js b/server/app.js
iindex 9787963..627f3b2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -275,7 +275,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
// dashboard, an API for Exchange. Nothing here writes an offer; the offer belongs to Network and the Link owner.
if (community) app.use('/api/v1', require('./api/community').communityRouter({ community, auth, log }));
app.use(adminRouter({ config, db, registry, pool, quotas, cache, runs, auth, settings, ops, log }));
-- app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, gpus, gpuMarket, abuse, priceBook, creditGrants, sessionCookies, vault, settings, config, log }));
++ app.use(require('./api/console-data').consoleDataRouter({ db, registry, pool, quotas, cache, runs, ops, auth, clock, fetchImpl, env, gpus, gpuMarket, abuse, priceBook, creditGrants, sessionCookies, vault, settings, config, log }));
// The operator console moved to OpenVibe.Exchange. Keep the legacy AI paths as permanent redirects;
// run receipts still use the person-session routes below and the AI JSON admin APIs stay here.
// Person sessions are separate, read-only and open one run receipt each (server/person-session.js).
ddiff --git a/server/owner-inputs.js b/server/owner-inputs.js
iindex ecc9d9c..1f3f63c 100644
---- a/server/owner-inputs.js
++++ b/server/owner-inputs.js
@@@ -115,9 +115,16 @@ async function testProviderKey(provider, key, { fetchImpl }) {
url = `${base}/models?pageSize=1`;
headers = { 'x-goog-api-key': key };
}
++ if (provider.metadata && provider.metadata.models_endpoint) url = provider.metadata.models_endpoint;
++ if (provider.auth_mode === 'none') headers = {};
++ else if (provider.kind !== 'anthropic' && provider.kind !== 'gemini' && provider.auth_mode === 'x-api-key') headers = { 'x-api-key': key };
++ // A configured models endpoint receives a key only on the origin the owner entered it for.
++ try { if (new URL(url).origin !== new URL(base).origin) return { ok: false, message: `${name} has a model-list address on a different origin.` }; }
++ catch { return { ok: false, message: `${name} has an invalid model-list address.` }; }
const r = await probe(fetchImpl, url, { headers });
if (r.status >= 200 && r.status < 300) {
const list = r.json && (Array.isArray(r.json.data) ? r.json.data : Array.isArray(r.json.models) ? r.json.models : null);
++ if (!list) return { ok: false, message: `${name} answered without a model list.` };
return { ok: true, message: `${name} accepted the key${list ? ` and lists ${list.length} model${list.length === 1 ? '' : 's'}` : ''}.` };
}
return { ok: false, message: refused(name, r) };
ddiff --git a/server/providers/check.js b/server/providers/check.js
nnew file mode 100644
iindex 0000000..9a98c9d
---- /dev/null
++++ b/server/providers/check.js
@@@ -0,0 +1,68 @@
++'use strict';
++
++const { providerSecret } = require('../util');
++const { testProviderKey } = require('../owner-inputs');
++const { guardedFetch } = require('./guarded-fetch');
++
++/** One explicit owner check, with no inference, retries, redirects or fallback. Never retain upstream text. */
++async function checkProvider(provider, { env = process.env, fetchImpl = guardedFetch, now = Date.now } = {}) {
++ const started = now();
++ let calls = 0;
++ let httpStatus = null;
++ const finish = (ok, error = null, method = null) => ({
++ ok, error, method, http_status: httpStatus, requests: calls,
++ latency_ms: Math.max(0, now() - started), checked_at: new Date(started).toISOString(),
++ });
++ const key = providerSecret(provider, env);
++ if (provider.auth_mode !== 'none' && !key) return finish(false, 'key_not_set');
++ if (!provider.base_url) return finish(false, 'base_url_not_set');
++ let origin;
++ try { origin = new URL(provider.base_url).origin; } catch { return finish(false, 'invalid_base_url'); }
++ let failure = 'request_failed';
++ const once = async (url, init = {}) => {
++ const target = new URL(String(url));
++ if (target.origin !== origin || target.protocol !== 'https:' || target.username || target.password) {
++ failure = 'endpoint_origin_mismatch';
++ throw new Error(failure);
++ }
++ if (calls) { failure = 'request_limit'; throw new Error(failure); }
++ calls++;
++ try {
++ const response = await fetchImpl(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(10000) });
++ httpStatus = response.status;
++ if (!response.ok) failure = `http_${response.status}`;
++ return response;
++ } catch (err) {
++ failure = ['AbortError', 'TimeoutError'].includes(err && err.name) ? 'timeout' : 'connection_failed';
++ throw new Error(failure);
++ }
++ };
++ let method = 'models';
++ try {
++ if (provider.metadata?.check_endpoint) {
++ method = 'account';
++ const header = provider.metadata.auth_header || (provider.auth_mode === 'x-api-key' ? 'x-api-key' : 'Authorization');
++ const headers = provider.auth_mode === 'none' ? {} : { [header]: header.toLowerCase() === 'authorization' ? `Bearer ${key}` : key };
++ const result = await once(provider.metadata.check_endpoint, { method: 'GET', headers });
++ // Account data and key labels are deliberately discarded.
++ await result.body?.cancel();
++ return finish(result.ok, result.ok ? null : failure, method);
++ }
++ if (provider.kind === 'http' && provider.metadata?.search_adapters?.length) {
++ method = 'search';
++ const { adapters } = require('../search');
++ const adapter = provider.metadata.search_adapters.map(id => adapters.find(a => a.id === id)).find(Boolean);
++ if (!adapter) return finish(false, 'check_not_supported', method);
++ await adapter.search({ q: 'OpenVibe', limit: 1 }, {
++ key, fetchImpl: once, now, env: { ...env, ...(adapter.setting ? { [adapter.setting]: provider.base_url } : {}) },
++ });
++ return finish(true, null, method);
++ }
++ const result = await testProviderKey(provider, key, { fetchImpl: once });
++ return finish(result.ok, result.ok ? null : httpStatus >= 200 && httpStatus < 300 ? 'invalid_response' : failure, method);
++ } catch {
++ return finish(false, failure, method);
++ }
++}
++
++module.exports = { checkProvider };
ddiff --git a/server/providers/pool-providers.js b/server/providers/pool-providers.js
iindex fc9aba7..bfd5361 100644
---- a/server/providers/pool-providers.js
++++ b/server/providers/pool-providers.js
@@@ -616,6 +616,7 @@ const PROVIDERS = [
},
{
key: 'openrouter', keys_url: 'https://openrouter.ai/settings/keys',
++ check_endpoint: 'https://openrouter.ai/api/v1/key',
docs_url: 'https://openrouter.ai/docs/api-reference/overview',
display_name: 'OpenRouter (the last fallback)',
base_url: 'https://openrouter.ai/api/v1',
@@@ -699,6 +700,7 @@ const PROVIDERS = [
auth_header: 'x-subscription-token', models_endpoint: null, capabilities: ['web-search', 'image-search'],
search_adapters: ['brave', 'brave-images'], docs_url: 'https://api-dashboard.search.brave.com/documentation/services/web-search' },
{ key: 'tavily', display_name: 'Tavily', kind: 'http', base_url: 'https://api.tavily.com', models_endpoint: null,
++ check_endpoint: 'https://api.tavily.com/usage',
capabilities: ['web-search'], search_adapters: ['tavily'], docs_url: 'https://docs.tavily.com/documentation/api-reference/endpoint/search' },
{ key: 'exa', display_name: 'Exa', kind: 'http', base_url: 'https://api.exa.ai', auth_mode: 'x-api-key', models_endpoint: null,
capabilities: ['web-search'], search_adapters: ['exa'], docs_url: 'https://docs.exa.ai/reference/search' },
ddiff --git a/server/providers/pool-seed.js b/server/providers/pool-seed.js
iindex 8ba9d23..86bb4b5 100644
---- a/server/providers/pool-seed.js
++++ b/server/providers/pool-seed.js
@@@ -29,6 +29,7 @@ function recordOf(p) {
metadata: {
catalog: 'pool-providers',
api_family: p.api_family, models_endpoint: p.models_endpoint,
++ check_endpoint: p.check_endpoint || null,
auth_header: p.auth_header || null, docs_url: p.docs_url || null,
search_adapters: p.search_adapters || [],
data_policy: p.data_policy,
@@@ -97,7 +98,10 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
} else if (prev.origin === 'catalog') {
const defaults = recordOf(p);
const configured = prev.auth_mode === 'none' || Boolean(providerSecret(prev, env));
-- if (configured && prev.base_url !== p.base_url) defaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
++ if (configured && prev.base_url !== p.base_url) {
++ defaults.metadata.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
++ defaults.metadata.check_endpoint = null;
++ }
const metadata = { ...defaults.metadata, ...prev.metadata };
if (!configured && prev.base_url !== p.base_url) {
metadata.models_endpoint = defaults.metadata.models_endpoint;
@@@ -116,8 +120,11 @@ async function seedPoolProviders({ registry, actor = 'system', env = process.env
} else {
out.skipped.push(p.key);
const protocol = recordOf(p).metadata;
-- if (prev.base_url !== p.base_url) protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
-- const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
++ if (prev.base_url !== p.base_url) {
++ protocol.models_endpoint = prev.base_url ? `${prev.base_url.replace(/\/+$/, '')}/models` : null;
++ protocol.check_endpoint = null;
++ }
++ const additions = Object.fromEntries(Object.entries(protocol).filter(([k]) => ['api_family', 'models_endpoint', 'check_endpoint', 'auth_header', 'docs_url', 'search_adapters'].includes(k) && prev.metadata[k] === undefined));
if (Object.keys(additions).length) await registry.upsertProvider({ key: p.key, metadata: { ...prev.metadata, ...additions } }, { actor, origin: prev.origin });
}
for (const m of p.models) {
ddiff --git a/test/provider-check.test.js b/test/provider-check.test.js
nnew file mode 100644
iindex 0000000..f30b06c
---- /dev/null
++++ b/test/provider-check.test.js
@@@ -0,0 +1,162 @@
++'use strict';
++
++const assert = require('assert');
++const crypto = require('crypto');
++const { checkProvider } = require('../server/providers/check');
++const { boot, request, token, suite } = require('./helpers');
++const { providerSecret } = require('../server/util');
++const t = suite('provider-check');
++const key = 'test-owner-key-never-in-response-1234';
++const replacement = 'test-replacement-key-never-in-response-5678';
++const env = { CHECK_KEY: key };
++const provider = { key: 'check', kind: 'openai', auth_mode: 'bearer', base_url: 'https://check.example/v1', secret_ref: 'env:CHECK_KEY' };
++const response = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
++const manage = token('exchange', ['ai.provider.manage', 'ai.usage.read']);
++const unprivileged = token('chat', ['ai.run.read']);
++
++t.test('a model-list check is one free read at the registered endpoint with latency', async () => {
++ const calls = [];
++ let ticks = 1000;
++ const result = await checkProvider({ ...provider, metadata: { models_endpoint: 'https://check.example/catalog/models' } }, {
++ env, now: () => (ticks += 25), fetchImpl: async (url, init) => {
++ calls.push(url);
++ assert.strictEqual(init.headers.Authorization, `Bearer ${key}`);
++ assert.strictEqual(init.method, 'GET');
++ assert.strictEqual(init.redirect, 'error');
++ return response(200, { data: [{ id: 'test' }] });
++ },
++ });
++ assert.deepStrictEqual(calls, ['https://check.example/catalog/models']);
++ assert.strictEqual(result.ok, true);
++ assert.strictEqual(result.requests, 1);
++ assert.strictEqual(result.latency_ms, 25);
++});
++
++t.test('upstream refusals and thrown errors cannot echo the key; no retries', async () => {
++ for (const failure of [401, 429, 500, 'throw']) {
++ let calls = 0;
++ const result = await checkProvider(provider, { env, fetchImpl: async () => {
++ calls++;
++ if (failure === 'throw') throw new Error(`bad key ${key}`);
++ return response(failure, { error: { message: `bad key ${key}` } });
++ } });
++ assert.strictEqual(calls, 1);
++ assert.strictEqual(result.ok, false);
++ assert.strictEqual(result.error, failure === 'throw' ? 'connection_failed' : `http_${failure}`);
++ assert.ok(!JSON.stringify(result).includes(key));
++ }
++});
++
++t.test('missing keys and cross-origin model lists never make a request', async () => {
++ const fetchImpl = async () => { throw new Error('must not fetch'); };
++ const missing = await checkProvider(provider, { env: {}, fetchImpl });
++ assert.strictEqual(missing.error, 'key_not_set');
++ const moved = await checkProvider({ ...provider, metadata: { models_endpoint: 'https://other.example/models' } }, { env, fetchImpl });
++ assert.strictEqual(moved.ok, false);
++ assert.strictEqual(moved.requests, 0);
++});
++
++t.test('search checks use their adapter once, with one result and no inference fallback', async () => {
++ let calls = 0;
++ const result = await checkProvider({ ...provider, kind: 'http', base_url: 'https://api.search.brave.com/res/v1', metadata: { search_adapters: ['brave', 'brave-images'] } }, {
++ env, fetchImpl: async (url, init) => {
++ calls++;
++ assert.strictEqual(new URL(url).searchParams.get('count'), '1');
++ assert.strictEqual(init.headers['x-subscription-token'], key);
++ return response(200, { web: { results: [] } });
++ },
++ });
++ assert.strictEqual(result.ok, true);
++ assert.strictEqual(result.method, 'search');
++ assert.strictEqual(calls, 1);
++});
++
++t.test('registered free account checks take precedence over model lists and searches', async () => {
++ let calls = 0;
++ const result = await checkProvider({ ...provider, kind: 'http', metadata: { check_endpoint: 'https://check.example/usage', search_adapters: ['brave'] } }, {
++ env, fetchImpl: async (url) => { calls++; assert.strictEqual(url, 'https://check.example/usage'); return response(200, { label: key }); },
++ });
++ assert.strictEqual(result.ok, true);
++ assert.strictEqual(result.method, 'account');
++ assert.strictEqual(calls, 1);
++ assert.ok(!JSON.stringify(result).includes(key));
++});
++
++let h;
++let requests = 0;
++let authUsed;
++const logs = [];
++const logger = { log: (...v) => logs.push(v.join(' ')), warn: (...v) => logs.push(v.join(' ')), error: (...v) => logs.push(v.join(' ')) };
++const api = (method, path, body, tok = manage) => request(h.base, method, path, { body, tok });
++const path = '/api/v1/admin/console/providers/keycheck';
++t.test('boot the real encrypted store and save keys without making provider calls', async () => {
++ h = await boot({ env: { OV_CONFIG_KEKS: `test:${crypto.randomBytes(32).toString('base64')}`, OV_CONFIG_KEK_CURRENT: 'test', AI_USER_MODULES: 'off', CHECK_KEY: key }, log: logger,
++ fetchImpl: async (url, init) => { requests++; authUsed = init.headers.Authorization; return response(200, { data: [{ id: 'test' }] }); },
++ });
++ await h.registry.upsertProvider({ ...provider, key: 'keycheck', status: 'active', capabilities: ['chat'] });
++ requests = 0;
++ for (const value of [key, replacement]) {
++ const saved = await api('POST', `${path}/key`, { api_key: value });
++ assert.strictEqual(saved.status, 200, saved.text);
++ assert.ok(!saved.text.includes(value));
++ }
++ assert.strictEqual(requests, 0);
++ assert.strictEqual(providerSecret(await h.registry.getProvider('keycheck'), h.env), replacement);
++ const read = await api('GET', '/api/v1/admin/console/providers');
++ const stored = read.body.providers.find(p => p.key === 'keycheck');
++ assert.strictEqual(stored.key_state.hint, '…5678');
++ assert.strictEqual(stored.key_state.source, 'stored');
++ assert.ok(stored.key_state.set_at);
++ assert.ok(!read.text.includes(replacement) && !read.text.includes(key));
++ const history = JSON.stringify(await h.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'ai.provider_keys'").all());
++ assert.ok(!history.includes(key) && !history.includes(replacement), 'all persisted key revisions are encrypted');
++});
++
++t.test('the check route makes one request and records only its safe result', async () => {
++ const before = requests;
++ const check = await api('POST', `${path}/check`);
++ assert.strictEqual(check.status, 200, check.text);
++ assert.strictEqual(check.body.ok, true);
++ assert.strictEqual(requests - before, 1);
++ assert.strictEqual(authUsed, `Bearer ${replacement}`);
++ const stored = await h.registry.getProvider('keycheck');
++ assert.strictEqual(stored.metadata.last_check.ok, true);
++ assert.strictEqual(stored.status, 'active');
++ assert.ok(!JSON.stringify(stored.metadata).includes(replacement));
++});
++
++t.test('a service without owner capabilities gets 403 for every key operation', async () => {
++ const before = requests;
++ for (const [method, suffix] of [['POST', 'key'], ['DELETE', 'key'], ['POST', 'check']]) {
++ const denied = await api(method, `${path}/${suffix}`, { api_key: key }, unprivileged);
++ assert.strictEqual(denied.status, 403, denied.text);
++ }
++ assert.strictEqual(requests, before);
++});
++
++t.test('checking a seeded environment provider preserves its configuration ownership', async () => {
++ await h.registry.upsertProvider({ ...provider, key: 'envcheck', status: 'active', capabilities: ['chat'] }, { origin: 'seed' });
++ const before = requests;
++ const check = await api('POST', '/api/v1/admin/console/providers/envcheck/check');
++ assert.strictEqual(check.status, 200, check.text);
++ assert.strictEqual(check.body.ok, true);
++ assert.strictEqual(requests - before, 1);
++ assert.strictEqual((await h.registry.getProvider('envcheck')).origin, 'seed');
++});
++
++t.test('removal disables immediately, clears the stored key and disconnects environment fallback', async () => {
++ const removed = await api('DELETE', `${path}/key`);
++ assert.strictEqual(removed.status, 200, removed.text);
++ const stored = await h.registry.getProvider('keycheck');
++ assert.strictEqual(stored.status, 'disabled');
++ assert.strictEqual(stored.secret_ref, null);
++ assert.strictEqual(providerSecret(stored, h.env), '');
++ assert.strictEqual(stored.metadata.last_check, null);
++ const before = requests;
++ const check = await api('POST', `${path}/check`);
++ assert.strictEqual(check.body.error, 'key_not_set');
++ assert.strictEqual(requests, before);
++ assert.ok(!logs.join('\n').includes(key) && !logs.join('\n').includes(replacement));
++});
++
++t.run(() => h && h.stop());