Add managed provider keys: encrypted store + env>DB resolver
AES-256-GCM encryption (KeyCipher) with master key from NEXUS_MASTER_KEY, never persisted. ProvidersStore in SQLite: CRUD, enable/disable, default, encrypted api keys, masked views for UI. KeyResolver applies env>DB precedence and supports multiple providers. context.ts registers providers dynamically from resolved config; backward compatible with env-only setups.
This commit is contained in:
@@ -14,6 +14,16 @@ NEXUS_EMBED_MAX_BYTES=10485760
|
|||||||
# Log level: debug | info | warn | error (logs go to stderr)
|
# Log level: debug | info | warn | error (logs go to stderr)
|
||||||
NEXUS_LOG_LEVEL=info
|
NEXUS_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# --- Managed provider keys (optional, for admin-managed keys) ---
|
||||||
|
# Master secret used to encrypt provider API keys stored in SQLite (AES-256-GCM).
|
||||||
|
# Accepts a 64-char hex string or any passphrase. NEVER stored in the DB.
|
||||||
|
# If unset, only env keys (e.g. NORDROUTER_API_KEY) are used.
|
||||||
|
# Generate: openssl rand -hex 32
|
||||||
|
# NEXUS_MASTER_KEY=
|
||||||
|
# Optional separate DB for the providers store (defaults to NEXUS_DB_PATH)
|
||||||
|
# NEXUS_PROVIDERS_DB_PATH=./media-output/nexus.sqlite
|
||||||
|
# Key precedence: <NAME>_API_KEY env > encrypted DB entry
|
||||||
|
|
||||||
# --- Local STT (faster-whisper via uv sidecar) ---
|
# --- Local STT (faster-whisper via uv sidecar) ---
|
||||||
# Launcher: "uv" (recommended, auto-installs deps) or a python interpreter path
|
# Launcher: "uv" (recommended, auto-installs deps) or a python interpreter path
|
||||||
NEXUS_STT_PYTHON=uv
|
NEXUS_STT_PYTHON=uv
|
||||||
|
|||||||
@@ -114,6 +114,32 @@ npm run build
|
|||||||
- **Журнал** SQLite (`nexus.sqlite` в media-dir) пишет каждую операцию; `nexus_usage_stats` агрегирует траты.
|
- **Журнал** SQLite (`nexus.sqlite` в media-dir) пишет каждую операцию; `nexus_usage_stats` агрегирует траты.
|
||||||
- MCP работает по stdio — **все логи идут в stderr**.
|
- MCP работает по stdio — **все логи идут в stderr**.
|
||||||
|
|
||||||
|
## Ключи провайдеров
|
||||||
|
|
||||||
|
Ключи можно задавать двумя способами, с приоритетом **env → БД**:
|
||||||
|
|
||||||
|
1. **Env (приоритет)** — `NORDROUTER_API_KEY` (и `<NAME>_API_KEY` для других провайдеров). Просто для CI и локального запуска.
|
||||||
|
2. **Зашифрованная БД** — управляется из будущей админки. Ключи хранятся в SQLite, зашифрованные **AES-256-GCM** на мастер-ключе `NEXUS_MASTER_KEY` (сам мастер-ключ — только в env, в БД его нет).
|
||||||
|
|
||||||
|
Свойства слоя:
|
||||||
|
- В git/логи/БД сырой ключ не попадает; в UI показывается только маска `sk-…last4`.
|
||||||
|
- Бэкап БД не раскрывает ключи (нужен мастер-ключ).
|
||||||
|
- Ротация `NEXUS_MASTER_KEY` инвалидирует зашифрованные ключи — их нужно ввести заново.
|
||||||
|
- Без `NEXUS_MASTER_KEY` работают только env-ключи (сервер предупреждает в stderr).
|
||||||
|
|
||||||
|
Программное управление (пока до админки) — через `@nexusai/core`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { KeyCipher, ProvidersStore } from '@nexusai/core';
|
||||||
|
const cipher = new KeyCipher(process.env.NEXUS_MASTER_KEY);
|
||||||
|
const store = new ProvidersStore('./media-output/nexus.sqlite', cipher);
|
||||||
|
store.upsert({ name: 'nordrouter', type: 'nordrouter', apiKey: 'sk-...', isDefault: true });
|
||||||
|
store.setEnabled('nordrouter', true);
|
||||||
|
store.listViews(); // masked view for UI
|
||||||
|
```
|
||||||
|
|
||||||
|
Генерация мастер-ключа: `openssl rand -hex 32`.
|
||||||
|
|
||||||
## STT в ограниченных сетях
|
## STT в ограниченных сетях
|
||||||
|
|
||||||
faster-whisper скачивает модель с HuggingFace. Если `huggingface.co` недоступен:
|
faster-whisper скачивает модель с HuggingFace. Если `huggingface.co` недоступен:
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export interface NexusConfig {
|
|||||||
defaultProvider: string;
|
defaultProvider: string;
|
||||||
mediaDir: string;
|
mediaDir: string;
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
|
/** SQLite path for the encrypted providers store (defaults to same as dbPath). */
|
||||||
|
providersDbPath: string;
|
||||||
|
/** Master secret for encrypting stored API keys (env NEXUS_MASTER_KEY). */
|
||||||
|
masterKey?: string;
|
||||||
embedMaxBytes: number;
|
embedMaxBytes: number;
|
||||||
nordrouter: NordRouterConfig;
|
nordrouter: NordRouterConfig;
|
||||||
stt: SttConfig;
|
stt: SttConfig;
|
||||||
@@ -40,6 +44,8 @@ export function loadConfig(): NexusConfig {
|
|||||||
defaultProvider: process.env.NEXUS_DEFAULT_PROVIDER || 'nordrouter',
|
defaultProvider: process.env.NEXUS_DEFAULT_PROVIDER || 'nordrouter',
|
||||||
mediaDir,
|
mediaDir,
|
||||||
dbPath,
|
dbPath,
|
||||||
|
providersDbPath: process.env.NEXUS_PROVIDERS_DB_PATH || dbPath,
|
||||||
|
masterKey: process.env.NEXUS_MASTER_KEY,
|
||||||
embedMaxBytes: envInt('NEXUS_EMBED_MAX_BYTES', 10 * 1024 * 1024),
|
embedMaxBytes: envInt('NEXUS_EMBED_MAX_BYTES', 10 * 1024 * 1024),
|
||||||
nordrouter: {
|
nordrouter: {
|
||||||
apiKey: process.env.NORDROUTER_API_KEY,
|
apiKey: process.env.NORDROUTER_API_KEY,
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { NexusError } from '../shared/errors.js';
|
||||||
|
import { createLogger } from '../shared/logger.js';
|
||||||
|
import { KeyCipher, maskSecret } from '../security/crypto.js';
|
||||||
|
|
||||||
|
const log = createLogger('providers-store');
|
||||||
|
|
||||||
|
export interface ProviderRow {
|
||||||
|
name: string;
|
||||||
|
type: string; // e.g. "nordrouter" | "routerai" | "openrouter"
|
||||||
|
baseUrl: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public view: never exposes the raw key, only a masked hint. */
|
||||||
|
export interface ProviderView extends ProviderRow {
|
||||||
|
hasKey: boolean;
|
||||||
|
keyHint: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertProviderInput {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
apiKey?: string; // plaintext; will be encrypted at rest
|
||||||
|
enabled?: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-backed provider registry with encrypted API keys.
|
||||||
|
* Managed by the admin (Phase 2); read by the MCP server at startup.
|
||||||
|
*/
|
||||||
|
export class ProvidersStore {
|
||||||
|
private readonly db: Database.Database;
|
||||||
|
|
||||||
|
constructor(dbPath: string, private readonly cipher: KeyCipher) {
|
||||||
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
this.db = new Database(dbPath);
|
||||||
|
this.db.pragma('journal_mode = WAL');
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(): void {
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS providers (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
base_url TEXT,
|
||||||
|
api_key_enc TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(input: UpsertProviderInput): void {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const encKey = input.apiKey !== undefined ? this.cipher.encrypt(input.apiKey) : undefined;
|
||||||
|
|
||||||
|
const existing = this.db.prepare('SELECT name FROM providers WHERE name = ?').get(input.name);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update only provided fields; keep existing key if not supplied.
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE providers SET
|
||||||
|
type = @type,
|
||||||
|
base_url = COALESCE(@baseUrl, base_url),
|
||||||
|
api_key_enc = COALESCE(@encKey, api_key_enc),
|
||||||
|
enabled = @enabled,
|
||||||
|
is_default = @isDefault,
|
||||||
|
updated_at = @now
|
||||||
|
WHERE name = @name`
|
||||||
|
)
|
||||||
|
.run({
|
||||||
|
name: input.name,
|
||||||
|
type: input.type,
|
||||||
|
baseUrl: input.baseUrl ?? null,
|
||||||
|
encKey: encKey ?? null,
|
||||||
|
enabled: input.enabled === false ? 0 : 1,
|
||||||
|
isDefault: input.isDefault ? 1 : 0,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO providers (name, type, base_url, api_key_enc, enabled, is_default, created_at, updated_at)
|
||||||
|
VALUES (@name, @type, @baseUrl, @encKey, @enabled, @isDefault, @now, @now)`
|
||||||
|
)
|
||||||
|
.run({
|
||||||
|
name: input.name,
|
||||||
|
type: input.type,
|
||||||
|
baseUrl: input.baseUrl ?? null,
|
||||||
|
encKey: encKey ?? null,
|
||||||
|
enabled: input.enabled === false ? 0 : 1,
|
||||||
|
isDefault: input.isDefault ? 1 : 0,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.isDefault) this.setDefault(input.name);
|
||||||
|
log.info(`provider upserted: ${input.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(name: string, enabled: boolean): void {
|
||||||
|
const r = this.db
|
||||||
|
.prepare('UPDATE providers SET enabled = ?, updated_at = ? WHERE name = ?')
|
||||||
|
.run(enabled ? 1 : 0, new Date().toISOString(), name);
|
||||||
|
if (r.changes === 0) throw new NexusError('NOT_FOUND', `Provider not found: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefault(name: string): void {
|
||||||
|
const exists = this.db.prepare('SELECT 1 FROM providers WHERE name = ?').get(name);
|
||||||
|
if (!exists) throw new NexusError('NOT_FOUND', `Provider not found: ${name}`);
|
||||||
|
const tx = this.db.transaction(() => {
|
||||||
|
this.db.prepare('UPDATE providers SET is_default = 0').run();
|
||||||
|
this.db
|
||||||
|
.prepare('UPDATE providers SET is_default = 1, updated_at = ? WHERE name = ?')
|
||||||
|
.run(new Date().toISOString(), name);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(name: string): void {
|
||||||
|
this.db.prepare('DELETE FROM providers WHERE name = ?').run(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the decrypted API key for a provider, or undefined. */
|
||||||
|
getApiKey(name: string): string | undefined {
|
||||||
|
const row = this.db.prepare('SELECT api_key_enc FROM providers WHERE name = ?').get(name) as
|
||||||
|
| { api_key_enc: string | null }
|
||||||
|
| undefined;
|
||||||
|
if (!row?.api_key_enc) return undefined;
|
||||||
|
return this.cipher.decrypt(row.api_key_enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(name: string): ProviderRow | undefined {
|
||||||
|
const row = this.db.prepare('SELECT * FROM providers WHERE name = ?').get(name) as any;
|
||||||
|
return row ? this.mapRow(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): ProviderRow[] {
|
||||||
|
const rows = this.db.prepare('SELECT * FROM providers ORDER BY name').all() as any[];
|
||||||
|
return rows.map((r) => this.mapRow(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin-safe listing: masked key hint, never the raw key. */
|
||||||
|
listViews(): ProviderView[] {
|
||||||
|
const rows = this.db.prepare('SELECT * FROM providers ORDER BY name').all() as any[];
|
||||||
|
return rows.map((r) => {
|
||||||
|
const base = this.mapRow(r);
|
||||||
|
let keyHint: string | null = null;
|
||||||
|
if (r.api_key_enc) {
|
||||||
|
try {
|
||||||
|
keyHint = maskSecret(this.cipher.decrypt(r.api_key_enc));
|
||||||
|
} catch {
|
||||||
|
keyHint = '(undecryptable)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...base, hasKey: Boolean(r.api_key_enc), keyHint };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRow(r: any): ProviderRow {
|
||||||
|
return {
|
||||||
|
name: r.name,
|
||||||
|
type: r.type,
|
||||||
|
baseUrl: r.base_url,
|
||||||
|
enabled: Boolean(r.enabled),
|
||||||
|
isDefault: Boolean(r.is_default),
|
||||||
|
createdAt: r.created_at,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ export * from './shared/logger.js';
|
|||||||
export * from './shared/http.js';
|
export * from './shared/http.js';
|
||||||
export * from './shared/polling.js';
|
export * from './shared/polling.js';
|
||||||
export * from './db/journal.js';
|
export * from './db/journal.js';
|
||||||
|
export * from './db/providers-store.js';
|
||||||
|
export * from './security/crypto.js';
|
||||||
|
export * from './security/resolver.js';
|
||||||
export * from './media/storage.js';
|
export * from './media/storage.js';
|
||||||
export * from './media/content.js';
|
export * from './media/content.js';
|
||||||
export * from './providers/types.js';
|
export * from './providers/types.js';
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { NexusError } from '../shared/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256-GCM encryption for API keys at rest.
|
||||||
|
*
|
||||||
|
* The master key is provided via env NEXUS_MASTER_KEY and NEVER stored in the DB.
|
||||||
|
* Only encrypted key material lives in SQLite, so backups / inspection of the DB
|
||||||
|
* do not leak provider API keys. Rotating NEXUS_MASTER_KEY invalidates stored
|
||||||
|
* ciphertext (re-enter keys), which is the intended security trade-off.
|
||||||
|
*
|
||||||
|
* Ciphertext format (base64): [ 12-byte IV | 16-byte auth tag | ciphertext ]
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALGO = 'aes-256-gcm';
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const TAG_LEN = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a 32-byte key from the env master secret. Accepts either a 64-char
|
||||||
|
* hex string (used directly) or any passphrase (hashed with SHA-256).
|
||||||
|
*/
|
||||||
|
export function deriveMasterKey(secret: string | undefined): Buffer | undefined {
|
||||||
|
if (!secret) return undefined;
|
||||||
|
const trimmed = secret.trim();
|
||||||
|
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||||
|
return Buffer.from(trimmed, 'hex');
|
||||||
|
}
|
||||||
|
return crypto.createHash('sha256').update(trimmed, 'utf8').digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyCipher {
|
||||||
|
private readonly key?: Buffer;
|
||||||
|
|
||||||
|
constructor(masterSecret: string | undefined) {
|
||||||
|
this.key = deriveMasterKey(masterSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabled(): boolean {
|
||||||
|
return this.key !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireKey(): Buffer {
|
||||||
|
if (!this.key) {
|
||||||
|
throw new NexusError(
|
||||||
|
'CONFIG',
|
||||||
|
'NEXUS_MASTER_KEY is not set; cannot encrypt/decrypt stored API keys'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt(plaintext: string): string {
|
||||||
|
const key = this.requireKey();
|
||||||
|
const iv = crypto.randomBytes(IV_LEN);
|
||||||
|
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
||||||
|
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, tag, enc]).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(payload: string): string {
|
||||||
|
const key = this.requireKey();
|
||||||
|
const raw = Buffer.from(payload, 'base64');
|
||||||
|
if (raw.length < IV_LEN + TAG_LEN) {
|
||||||
|
throw new NexusError('CONFIG', 'Malformed encrypted key payload');
|
||||||
|
}
|
||||||
|
const iv = raw.subarray(0, IV_LEN);
|
||||||
|
const tag = raw.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||||
|
const enc = raw.subarray(IV_LEN + TAG_LEN);
|
||||||
|
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
try {
|
||||||
|
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
|
||||||
|
} catch {
|
||||||
|
throw new NexusError(
|
||||||
|
'CONFIG',
|
||||||
|
'Failed to decrypt API key (wrong NEXUS_MASTER_KEY or corrupted data)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mask a secret for display: keep last 4 chars. */
|
||||||
|
export function maskSecret(secret: string): string {
|
||||||
|
if (secret.length <= 4) return '****';
|
||||||
|
return `${secret.slice(0, 3)}…${secret.slice(-4)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { NexusConfig } from '../config.js';
|
||||||
|
import type { ProvidersStore } from '../db/providers-store.js';
|
||||||
|
import { createLogger } from '../shared/logger.js';
|
||||||
|
|
||||||
|
const log = createLogger('key-resolver');
|
||||||
|
|
||||||
|
export interface ResolvedProvider {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
/** Where the key came from, for diagnostics. */
|
||||||
|
keySource: 'env' | 'db' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve effective provider settings.
|
||||||
|
*
|
||||||
|
* Precedence for keys: ENV wins over DB. This keeps simple/CI setups working
|
||||||
|
* (just set NORDROUTER_API_KEY) while allowing admin-managed keys in the DB.
|
||||||
|
*
|
||||||
|
* Built-in providers (nordrouter) are always considered; DB rows can add more
|
||||||
|
* or override base URL / enabled / default.
|
||||||
|
*/
|
||||||
|
export class KeyResolver {
|
||||||
|
constructor(
|
||||||
|
private readonly config: NexusConfig,
|
||||||
|
private readonly store?: ProvidersStore
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private envKeyFor(providerName: string): string | undefined {
|
||||||
|
// Convention: <NAME>_API_KEY, e.g. NORDROUTER_API_KEY
|
||||||
|
const envName = `${providerName.toUpperCase()}_API_KEY`;
|
||||||
|
return process.env[envName];
|
||||||
|
}
|
||||||
|
|
||||||
|
private envBaseUrlFor(providerName: string): string | undefined {
|
||||||
|
const envName = `${providerName.toUpperCase()}_BASE_URL`;
|
||||||
|
return process.env[envName];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve one provider by name. */
|
||||||
|
resolve(providerName: string, defaultBaseUrl: string): ResolvedProvider {
|
||||||
|
const dbRow = this.store?.get(providerName);
|
||||||
|
const envKey = this.envKeyFor(providerName);
|
||||||
|
const dbKey = this.store?.getApiKey(providerName);
|
||||||
|
|
||||||
|
const apiKey = envKey ?? dbKey;
|
||||||
|
const keySource: ResolvedProvider['keySource'] = envKey ? 'env' : dbKey ? 'db' : 'none';
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
this.envBaseUrlFor(providerName) || dbRow?.baseUrl || defaultBaseUrl;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: providerName,
|
||||||
|
type: dbRow?.type ?? providerName,
|
||||||
|
baseUrl,
|
||||||
|
apiKey,
|
||||||
|
enabled: dbRow ? dbRow.enabled : true,
|
||||||
|
isDefault: dbRow?.isDefault ?? false,
|
||||||
|
keySource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All providers to register: built-ins + any DB-managed ones. */
|
||||||
|
resolveAll(builtins: Array<{ name: string; defaultBaseUrl: string }>): ResolvedProvider[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: ResolvedProvider[] = [];
|
||||||
|
|
||||||
|
for (const b of builtins) {
|
||||||
|
out.push(this.resolve(b.name, b.defaultBaseUrl));
|
||||||
|
seen.add(b.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of this.store?.list() ?? []) {
|
||||||
|
if (seen.has(row.name)) continue;
|
||||||
|
out.push(this.resolve(row.name, row.baseUrl || ''));
|
||||||
|
seen.add(row.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
'resolved providers',
|
||||||
|
out.map((p) => ({ name: p.name, key: p.keySource, enabled: p.enabled }))
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The effective default provider name. */
|
||||||
|
resolveDefault(resolved: ResolvedProvider[]): string {
|
||||||
|
const dbDefault = resolved.find((p) => p.isDefault && p.enabled);
|
||||||
|
if (dbDefault) return dbDefault.name;
|
||||||
|
return this.config.defaultProvider;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
Journal,
|
Journal,
|
||||||
|
KeyCipher,
|
||||||
|
KeyResolver,
|
||||||
MediaStorage,
|
MediaStorage,
|
||||||
ProviderRegistry,
|
ProviderRegistry,
|
||||||
|
ProvidersStore,
|
||||||
createNordRouterProviders,
|
createNordRouterProviders,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
LocalWhisperProvider,
|
LocalWhisperProvider,
|
||||||
@@ -16,10 +19,17 @@ export interface AppContext {
|
|||||||
registry: ProviderRegistry;
|
registry: ProviderRegistry;
|
||||||
storage: MediaStorage;
|
storage: MediaStorage;
|
||||||
journal: Journal;
|
journal: Journal;
|
||||||
|
providersStore: ProvidersStore;
|
||||||
|
/** Effective default provider name after resolving DB/env. */
|
||||||
|
defaultProvider: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Built-in providers always considered, even without a DB row. */
|
||||||
|
const BUILTINS = [{ name: 'nordrouter', defaultBaseUrl: 'https://nordrouter.com' }];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire up all providers, storage and the journal once at startup.
|
* Wire up providers, storage, journal and the encrypted providers store.
|
||||||
|
* API keys resolve with ENV > DB precedence (see KeyResolver).
|
||||||
*/
|
*/
|
||||||
export function buildContext(): AppContext {
|
export function buildContext(): AppContext {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -27,20 +37,50 @@ export function buildContext(): AppContext {
|
|||||||
const journal = new Journal(config.dbPath);
|
const journal = new Journal(config.dbPath);
|
||||||
const registry = new ProviderRegistry();
|
const registry = new ProviderRegistry();
|
||||||
|
|
||||||
// NordRouter (media + search). Registered even without a key so error messages
|
const cipher = new KeyCipher(config.masterKey);
|
||||||
// are clear when a tool is actually invoked.
|
const providersStore = new ProvidersStore(config.providersDbPath, cipher);
|
||||||
try {
|
const resolver = new KeyResolver(config, providersStore);
|
||||||
const nr = createNordRouterProviders({ config: config.nordrouter, storage, save: true });
|
|
||||||
registry.registerMedia(nr.media);
|
if (!cipher.enabled) {
|
||||||
registry.registerSearch(nr.search);
|
log.warn('NEXUS_MASTER_KEY not set — DB-stored API keys are unavailable; using env keys only');
|
||||||
log.info('registered nordrouter provider (media + search)');
|
}
|
||||||
} catch (err) {
|
|
||||||
log.warn('nordrouter not registered', err instanceof Error ? err.message : String(err));
|
const resolved = resolver.resolveAll(BUILTINS);
|
||||||
|
const defaultProvider = resolver.resolveDefault(resolved);
|
||||||
|
|
||||||
|
for (const p of resolved) {
|
||||||
|
if (!p.enabled) {
|
||||||
|
log.info(`provider "${p.name}" disabled — skipped`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p.type === 'nordrouter') {
|
||||||
|
if (!p.apiKey) {
|
||||||
|
log.warn(`provider "${p.name}" has no API key (env or DB) — tools will error when used`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nr = createNordRouterProviders({
|
||||||
|
config: { apiKey: p.apiKey, baseUrl: p.baseUrl },
|
||||||
|
storage,
|
||||||
|
save: true,
|
||||||
|
});
|
||||||
|
// Register under the provider's own name so multiple nordrouter-type
|
||||||
|
// providers with different keys can coexist.
|
||||||
|
Object.defineProperty(nr.media, 'name', { value: p.name });
|
||||||
|
Object.defineProperty(nr.search, 'name', { value: p.name });
|
||||||
|
registry.registerMedia(nr.media);
|
||||||
|
registry.registerSearch(nr.search);
|
||||||
|
log.info(`registered provider "${p.name}" (media + search, key: ${p.keySource})`);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(`failed to register "${p.name}"`, err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn(`provider type "${p.type}" (${p.name}) not implemented yet — skipped`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local STT (faster-whisper sidecar).
|
|
||||||
registry.registerStt(new LocalWhisperProvider(config.stt));
|
registry.registerStt(new LocalWhisperProvider(config.stt));
|
||||||
log.info('registered local STT provider');
|
log.info('registered local STT provider');
|
||||||
|
log.info(`default provider: ${defaultProvider}`);
|
||||||
|
|
||||||
return { config, registry, storage, journal };
|
return { config, registry, storage, journal, providersStore, defaultProvider };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export class Handlers {
|
|||||||
constructor(private readonly ctx: AppContext) {}
|
constructor(private readonly ctx: AppContext) {}
|
||||||
|
|
||||||
private providerName(explicit?: string): string {
|
private providerName(explicit?: string): string {
|
||||||
return explicit || this.ctx.config.defaultProvider;
|
return explicit || this.ctx.defaultProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handle(name: string, args: Record<string, unknown>): Promise<McpResult> {
|
async handle(name: string, args: Record<string, unknown>): Promise<McpResult> {
|
||||||
|
|||||||
Reference in New Issue
Block a user