openvibers/openvibe.ai Commits
A credential's models or budget change without the key; a new provider or base URL needs it again (openvibe-contracts 0.73.0 -> 0.74.0, ai.credential-put@1 api_key optional after the first put). A key never follows a new endpoint on its own: 400 credential.key_required. test/credentials.test.js covers both. npm test passes.
Changes
@@@ -13,7 +13,7 @@"better-sqlite3": "^11.10.0","dotenv": "^16.4.7","express": "^4.21.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.73.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.74.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"},@@@ -1395,9 +1395,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.73.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.73.0",-- "integrity": "sha512-SPgU/Ys9TLa8K+++V38C3uK50hNXXUeSPrIyWBT9HdY7eOsE/dnNRZqQnuUzihHOK22aQI15bRBE7u944tW0WA==",++ "version": "0.74.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.74.0",++ "integrity": "sha512-ZwlzNihXHXd/qEzBqzsmu5sPZLMS2LUcaX9f3eJcqFYMfhrXzqcmQYbRzLVxN8UvAgDTUiH9WIcTdblPmflNmA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -19,7 +19,7 @@"better-sqlite3": "^11.10.0","dotenv": "^16.4.7","express": "^4.21.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.73.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.74.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"},@@@ -82,8 +82,13 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {const v = validate('ai.credential-put@1', body);if (!v.valid) throw new AiError(422, 'input.invalid', 'body does not match ai.credential-put@1', { errors: v.errors });const at = iso(clock.now());-- const key = String(body.api_key);const prev = row(owner, subject);++ // The stored key stays for a change of models or budget; a new provider or endpoint needs the key again.++ const sameEndpoint = prev && prev.provider === body.provider && (prev.base_url || null) === (body.base_url || null);++ if (body.api_key == null && !sameEndpoint) {++ throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the provider or its address' : 'api_key is required the first time');++ }++ const key = body.api_key != null ? String(body.api_key) : open(prev.key_enc, `${owner}|${subject}`);db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, key_enc, key_hint, models, budget_usd_day, created_at, updated_at)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)ON CONFLICT(owner, subject) DO UPDATE SET provider = excluded.provider, base_url = excluded.base_url, key_enc = excluded.key_enc,@@@ -42,6 +42,11 @@ t.test('store, read without the key, refuse other services', async () => {assert.ok(validate('ai.credential@1', r.body).valid, JSON.stringify(validate('ai.credential@1', r.body).errors));const row = h.db.prepare('SELECT key_enc FROM subject_credentials WHERE owner = ? AND subject = ?').get('live', DANA);assert.ok(row.key_enc.startsWith('v1:') && !row.key_enc.includes(API_KEY), 'encrypted at rest');++ // A models change keeps the stored key; a new endpoint needs it again.++ r = await request(h.base, 'PUT', `/api/v1/credentials/${DANA}`, { tok: live, body: { provider: 'openai', base_url: 'https://llm.example.com/v1', models: { chat: 'gpt-4o-mini', vision: 'gpt-4o' }, budget_usd_per_day: 0.003 } });++ assert.deepStrictEqual([r.status, r.body.key_hint, r.body.models.vision], [200, '…WXYZ', 'gpt-4o']);++ r = await request(h.base, 'PUT', `/api/v1/credentials/${DANA}`, { tok: live, body: { provider: 'openai', base_url: 'https://attacker.example/v1' } });++ assert.deepStrictEqual([r.status, r.body.code], [400, 'credential.key_required']);r = await request(h.base, 'GET', `/api/v1/credentials/${DANA}`, { tok: tools });assert.strictEqual(r.status, 404, 'another service does not see it');// A row moved to another owner does not decrypt: the key is bound to owner and subject.
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.73.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
@@ -1395,9 +1395,9 @@
@@ -1395,9 +1395,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.73.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.73.0",
- "integrity": "sha512-SPgU/Ys9TLa8K+++V38C3uK50hNXXUeSPrIyWBT9HdY7eOsE/dnNRZqQnuUzihHOK22aQI15bRBE7u944tW0WA==",
+ "version": "0.74.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.74.0",
+ "integrity": "sha512-ZwlzNihXHXd/qEzBqzsmu5sPZLMS2LUcaX9f3eJcqFYMfhrXzqcmQYbRzLVxN8UvAgDTUiH9WIcTdblPmflNmA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.73.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0"
},
},
@@ -82,8 +82,13 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
@@ -82,8 +82,13 @@ function createCredentials({ db, config, clock = { now: () => Date.now() } }) {
const v = validate('ai.credential-put@1', body);
const v = validate('ai.credential-put@1', body);
if (!v.valid) throw new AiError(422, 'input.invalid', 'body does not match ai.credential-put@1', { errors: v.errors });
if (!v.valid) throw new AiError(422, 'input.invalid', 'body does not match ai.credential-put@1', { errors: v.errors });
const at = iso(clock.now());
const at = iso(clock.now());
- const key = String(body.api_key);
const prev = row(owner, subject);
const prev = row(owner, subject);
+ // The stored key stays for a change of models or budget; a new provider or endpoint needs the key again.
+ const sameEndpoint = prev && prev.provider === body.provider && (prev.base_url || null) === (body.base_url || null);
+ if (body.api_key == null && !sameEndpoint) {
+ throw new AiError(400, 'credential.key_required', prev ? 'enter the key again to change the provider or its address' : 'api_key is required the first time');
+ }
+ const key = body.api_key != null ? String(body.api_key) : open(prev.key_enc, `${owner}|${subject}`);
db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, key_enc, key_hint, models, budget_usd_day, created_at, updated_at)
db.prepare(`INSERT INTO subject_credentials (owner, subject, provider, base_url, key_enc, key_hint, models, budget_usd_day, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner, subject) DO UPDATE SET provider = excluded.provider, base_url = excluded.base_url, key_enc = excluded.key_enc,
ON CONFLICT(owner, subject) DO UPDATE SET provider = excluded.provider, base_url = excluded.base_url, key_enc = excluded.key_enc,
@@ -42,6 +42,11 @@ t.test('store, read without the key, refuse other services', async () => {
@@ -42,6 +42,11 @@ t.test('store, read without the key, refuse other services', async () => {
assert.ok(validate('ai.credential@1', r.body).valid, JSON.stringify(validate('ai.credential@1', r.body).errors));
assert.ok(validate('ai.credential@1', r.body).valid, JSON.stringify(validate('ai.credential@1', r.body).errors));
const row = h.db.prepare('SELECT key_enc FROM subject_credentials WHERE owner = ? AND subject = ?').get('live', DANA);
const row = h.db.prepare('SELECT key_enc FROM subject_credentials WHERE owner = ? AND subject = ?').get('live', DANA);
assert.ok(row.key_enc.startsWith('v1:') && !row.key_enc.includes(API_KEY), 'encrypted at rest');
assert.ok(row.key_enc.startsWith('v1:') && !row.key_enc.includes(API_KEY), 'encrypted at rest');
+ // A models change keeps the stored key; a new endpoint needs it again.
+ r = await request(h.base, 'PUT', `/api/v1/credentials/${DANA}`, { tok: live, body: { provider: 'openai', base_url: 'https://llm.example.com/v1', models: { chat: 'gpt-4o-mini', vision: 'gpt-4o' }, budget_usd_per_day: 0.003 } });
+ assert.deepStrictEqual([r.status, r.body.key_hint, r.body.models.vision], [200, '…WXYZ', 'gpt-4o']);
+ r = await request(h.base, 'PUT', `/api/v1/credentials/${DANA}`, { tok: live, body: { provider: 'openai', base_url: 'https://attacker.example/v1' } });
+ assert.deepStrictEqual([r.status, r.body.code], [400, 'credential.key_required']);
r = await request(h.base, 'GET', `/api/v1/credentials/${DANA}`, { tok: tools });
r = await request(h.base, 'GET', `/api/v1/credentials/${DANA}`, { tok: tools });
assert.strictEqual(r.status, 404, 'another service does not see it');
assert.strictEqual(r.status, 404, 'another service does not see it');
// A row moved to another owner does not decrypt: the key is bound to owner and subject.
// A row moved to another owner does not decrypt: the key is bound to owner and subject.