Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 231 additions & 151 deletions bot.js

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions public/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,27 @@ <h2>Ingestion</h2>
<h2>Providers</h2>
<div class="grid">
<div class="field"><label for="storageMode">Storage mode</label><select id="storageMode"><option>local</option><option>s3</option></select></div>
<div class="field"><label for="s3Endpoint">S3 endpoint</label><input id="s3Endpoint" /></div>
<div class="field"><label for="s3BucketName">S3 bucket</label><input id="s3BucketName" /></div>
<div class="field"><label for="s3AccessKeyId">S3 access key ID</label><input id="s3AccessKeyId" type="password" placeholder="Write-only replacement" /></div>
<div class="field"><label for="s3SecretAccessKey">S3 secret access key</label><input id="s3SecretAccessKey" type="password" placeholder="Write-only replacement" /></div>
<div class="field"><label for="transcriptionMode">Transcription mode</label><select id="transcriptionMode"><option>local</option><option>remote</option><option>openai</option><option>icad</option></select></div>
<div class="field"><label for="transcriptionDevice">Transcription device</label><select id="transcriptionDevice"><option>cpu</option><option>cuda</option></select></div>
<div class="field"><label for="aiProvider">AI provider</label><select id="aiProvider"><option>ollama</option><option>openai</option></select></div>
<div class="field"><label for="ollamaUrl">Ollama URL</label><input id="ollamaUrl" /></div>
<div class="field"><label for="ollamaModel">Ollama model</label><input id="ollamaModel" /></div>
<div class="field"><label for="openaiModel">OpenAI model</label><input id="openaiModel" /></div>
<div class="field"><label for="fasterWhisperServerUrl">Remote transcription URL</label><input id="fasterWhisperServerUrl" /></div>
<div class="field"><label for="whisperModel">Remote whisper model</label><input id="whisperModel" /></div>
<div class="field"><label for="openaiTranscriptionModel">OpenAI transcription model</label><input id="openaiTranscriptionModel" /></div>
<div class="field"><label for="openaiTranscriptionTemperature">OpenAI transcription temperature</label><input id="openaiTranscriptionTemperature" /></div>
<div class="field"><label for="openaiTranscriptionPrompt">OpenAI transcription prompt</label><input id="openaiTranscriptionPrompt" /></div>
<div class="field"><label for="icadUrl">iCAD URL</label><input id="icadUrl" /></div>
<div class="field"><label for="icadProfile">iCAD profile</label><input id="icadProfile" /></div>
<div class="field"><label for="googleMapsApiKey">Google Maps API key</label><input id="googleMapsApiKey" type="password" placeholder="Write-only replacement" /></div>
<div class="field"><label for="locationIqApiKey">LocationIQ API key</label><input id="locationIqApiKey" type="password" placeholder="Write-only replacement" /></div>
<div class="field"><label for="openaiApiKey">OpenAI API key</label><input id="openaiApiKey" type="password" placeholder="Write-only replacement" /></div>
<div class="field"><label for="icadApiKey">iCAD API key</label><input id="icadApiKey" type="password" placeholder="Write-only replacement" /></div>
</div>
</div>

Expand Down
7 changes: 4 additions & 3 deletions public/settings.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
const normalKeys = [
'publicDomain', 'timezone', 'summaryLookbackHours', 'askAiLookbackHours',
'mappedTalkGroups', 'enableMappedTalkGroups', 'storageMode', 'transcriptionMode',
'transcriptionDevice', 'aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel',
'fasterWhisperServerUrl'
's3Endpoint', 's3BucketName', 'transcriptionDevice', 'aiProvider', 'ollamaUrl', 'ollamaModel', 'openaiModel',
'fasterWhisperServerUrl', 'whisperModel', 'openaiTranscriptionPrompt',
'openaiTranscriptionModel', 'openaiTranscriptionTemperature', 'icadUrl', 'icadProfile'
];
const secretKeys = ['uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey'];
const secretKeys = ['uploadApiKey', 'googleMapsApiKey', 'locationIqApiKey', 'openaiApiKey', 'icadApiKey', 's3AccessKeyId', 's3SecretAccessKey'];

function showStep(id) {
document.querySelectorAll('.section').forEach((section) => section.classList.toggle('active', section.id === id));
Expand Down
16 changes: 16 additions & 0 deletions public/setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ <h2>Providers</h2>
<label for="timezone">Timezone</label>
<input id="timezone" value="US/Eastern" />
</div>
<div class="field">
<label for="s3-endpoint">S3 endpoint</label>
<input id="s3-endpoint" placeholder="https://minio.example.com" />
</div>
<div class="field">
<label for="s3-bucket">S3 bucket</label>
<input id="s3-bucket" placeholder="scanner-audio" />
</div>
<div class="field">
<label for="s3-access-key">S3 access key ID</label>
<input id="s3-access-key" type="password" placeholder="Write-only" />
</div>
<div class="field">
<label for="s3-secret-key">S3 secret access key</label>
<input id="s3-secret-key" type="password" placeholder="Write-only" />
</div>
<div class="field">
<label for="upload-key">Upload API key</label>
<input id="upload-key" type="password" placeholder="Write-only" />
Expand Down
18 changes: 18 additions & 0 deletions public/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ document.getElementById('save-providers').addEventListener('click', async () =>
method: 'POST',
body: JSON.stringify({
storageMode: document.getElementById('storage-mode').value,
s3Endpoint: document.getElementById('s3-endpoint').value,
s3BucketName: document.getElementById('s3-bucket').value,
transcriptionMode: document.getElementById('transcription-mode').value,
aiProvider: document.getElementById('ai-provider').value,
timezone: document.getElementById('timezone').value
Expand All @@ -95,6 +97,22 @@ document.getElementById('save-providers').addEventListener('click', async () =>
});
}

const s3AccessKey = document.getElementById('s3-access-key').value;
if (s3AccessKey) {
await jsonFetch('/api/setup/secrets', {
method: 'POST',
body: JSON.stringify({ key: 's3AccessKeyId', value: s3AccessKey })
});
}

const s3SecretKey = document.getElementById('s3-secret-key').value;
if (s3SecretKey) {
await jsonFetch('/api/setup/secrets', {
method: 'POST',
body: JSON.stringify({ key: 's3SecretAccessKey', value: s3SecretKey })
});
}

renderMessage('provider-result', 'Provider settings saved. Restart may be required for some settings.');
await loadStatus();
} catch (error) {
Expand Down
49 changes: 49 additions & 0 deletions src/settings/settingsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const SETTING_DEFINITIONS = {
publicDomain: { envKey: 'PUBLIC_DOMAIN', defaultValue: 'localhost', requiresRestart: true },
timezone: { envKey: 'TIMEZONE', defaultValue: 'US/Eastern', requiresRestart: false },
storageMode: { envKey: 'STORAGE_MODE', defaultValue: 'local', requiresRestart: true },
s3Endpoint: { envKey: 'S3_ENDPOINT', defaultValue: '', requiresRestart: true },
s3BucketName: { envKey: 'S3_BUCKET_NAME', defaultValue: '', requiresRestart: true },
transcriptionMode: { envKey: 'TRANSCRIPTION_MODE', defaultValue: 'local', requiresRestart: true },
transcriptionDevice: { envKey: 'TRANSCRIPTION_DEVICE', defaultValue: 'cpu', requiresRestart: true },
aiProvider: { envKey: 'AI_PROVIDER', defaultValue: 'ollama', requiresRestart: false },
Expand All @@ -14,6 +16,11 @@ const SETTING_DEFINITIONS = {
openaiModel: { envKey: 'OPENAI_MODEL', defaultValue: 'gpt-4o-mini', requiresRestart: false },
fasterWhisperServerUrl: { envKey: 'FASTER_WHISPER_SERVER_URL', defaultValue: '', requiresRestart: false },
whisperModel: { envKey: 'WHISPER_MODEL', defaultValue: 'large-v3', requiresRestart: false },
openaiTranscriptionPrompt: { envKey: 'OPENAI_TRANSCRIPTION_PROMPT', defaultValue: '', requiresRestart: false },
openaiTranscriptionModel: { envKey: 'OPENAI_TRANSCRIPTION_MODEL', defaultValue: 'whisper-1', requiresRestart: false },
openaiTranscriptionTemperature: { envKey: 'OPENAI_TRANSCRIPTION_TEMPERATURE', defaultValue: '0.0', requiresRestart: false },
icadUrl: { envKey: 'ICAD_URL', defaultValue: '', requiresRestart: false },
icadProfile: { envKey: 'ICAD_PROFILE', defaultValue: 'whisper-1', requiresRestart: false },
mappedTalkGroups: { envKey: 'MAPPED_TALK_GROUPS', defaultValue: '', requiresRestart: false },
enableMappedTalkGroups: { envKey: 'ENABLE_MAPPED_TALK_GROUPS', defaultValue: 'true', requiresRestart: false },
summaryLookbackHours: { envKey: 'SUMMARY_LOOKBACK_HOURS', defaultValue: '1', requiresRestart: false },
Expand Down Expand Up @@ -175,6 +182,45 @@ async function resolveSettings(db, env = process.env) {
return { settings, secrets };
}

async function getRuntimeSetting(db, key, env = process.env) {
const definition = SETTING_DEFINITIONS[key];
if (!definition) return undefined;

const row = await get(db, 'SELECT value FROM app_settings WHERE key = ? AND is_secret = 0', [key]);
if (row && row.value !== undefined && row.value !== null) return row.value;
if (env[definition.envKey] !== undefined && env[definition.envKey] !== '') return env[definition.envKey];
return definition.defaultValue;
}

async function getRuntimeSecret(db, key, options = {}) {
const definition = SECRET_DEFINITIONS[key];
if (!definition) return undefined;

const env = options.env || process.env;
const row = await get(db, 'SELECT value FROM app_settings WHERE key = ? AND is_secret = 1', [key]);
if (row && row.value) {
const instanceSecret = getInstanceSecret({ env });
return decryptSecret(row.value, instanceSecret);
}

return env[definition.envKey] || '';
}

async function getRuntimeConfig(db, env = process.env) {
const settings = {};
const secrets = {};

for (const key of Object.keys(SETTING_DEFINITIONS)) {
settings[key] = await getRuntimeSetting(db, key, env);
}

for (const key of Object.keys(SECRET_DEFINITIONS)) {
secrets[key] = await getRuntimeSecret(db, key, { env });
}

return { settings, secrets };
}

async function saveSettings(db, values, actor = 'admin') {
const results = {};

Expand Down Expand Up @@ -269,6 +315,9 @@ module.exports = {
decryptSecret,
encryptSecret,
getInstanceSecret,
getRuntimeConfig,
getRuntimeSecret,
getRuntimeSetting,
getSetupStatus,
resolveSettings,
saveSecret,
Expand Down
39 changes: 31 additions & 8 deletions src/setup/checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,25 @@ function checkWritableDir(dirPath) {
async function runSetupChecks(options = {}) {
const rootDir = options.rootDir || path.join(__dirname, '..', '..');
const env = options.env || process.env;
const runtime = options.runtime || { settings: {}, secrets: {} };
const storageMode = (runtime.settings.storageMode || env.STORAGE_MODE || 'local').toLowerCase();
const transcriptionMode = (runtime.settings.transcriptionMode || env.TRANSCRIPTION_MODE || 'local').toLowerCase();
const aiProvider = (runtime.settings.aiProvider || env.AI_PROVIDER || 'ollama').toLowerCase();
const hasS3Config = Boolean(
runtime.settings.s3Endpoint || env.S3_ENDPOINT
) && Boolean(
runtime.settings.s3BucketName || env.S3_BUCKET_NAME
) && Boolean(
runtime.secrets.s3AccessKeyId || env.S3_ACCESS_KEY_ID
) && Boolean(
runtime.secrets.s3SecretAccessKey || env.S3_SECRET_ACCESS_KEY
);
const transcriptionReady =
transcriptionMode === 'local' ||
(transcriptionMode === 'remote' && Boolean(runtime.settings.fasterWhisperServerUrl || env.FASTER_WHISPER_SERVER_URL)) ||
(transcriptionMode === 'openai' && Boolean(runtime.secrets.openaiApiKey || env.OPENAI_API_KEY)) ||
(transcriptionMode === 'icad' && Boolean(runtime.settings.icadUrl || env.ICAD_URL));
const aiReady = aiProvider !== 'openai' || Boolean(runtime.secrets.openaiApiKey || env.OPENAI_API_KEY);
const [node, python, ffmpeg, ollama] = await Promise.all([
checkCommand(process.execPath, ['--version']),
checkCommand(env.PYTHON_COMMAND || (process.platform === 'win32' ? 'py' : 'python3'), ['--version']),
Expand All @@ -57,22 +76,26 @@ async function runSetupChecks(options = {}) {
dataDir: checkWritableDir(path.join(rootDir, 'data')),
audioDir: checkWritableDir(path.join(rootDir, 'audio')),
geocodingProvider: {
ok: Boolean(env.GOOGLE_MAPS_API_KEY || env.LOCATIONIQ_API_KEY),
ok: Boolean(runtime.secrets.googleMapsApiKey || runtime.secrets.locationIqApiKey || env.GOOGLE_MAPS_API_KEY || env.LOCATIONIQ_API_KEY),
configuredProviders: {
google: Boolean(env.GOOGLE_MAPS_API_KEY),
locationiq: Boolean(env.LOCATIONIQ_API_KEY)
google: Boolean(runtime.secrets.googleMapsApiKey || env.GOOGLE_MAPS_API_KEY),
locationiq: Boolean(runtime.secrets.locationIqApiKey || env.LOCATIONIQ_API_KEY)
}
},
transcriptionProvider: {
ok: Boolean(env.TRANSCRIPTION_MODE || 'local'),
mode: env.TRANSCRIPTION_MODE || 'local'
ok: transcriptionReady,
mode: transcriptionMode
},
aiProvider: {
ok: Boolean(env.AI_PROVIDER || 'ollama'),
provider: env.AI_PROVIDER || 'ollama'
ok: aiReady,
provider: aiProvider
},
storageProvider: {
ok: storageMode === 'local' || hasS3Config,
mode: storageMode
},
uploadEndpoint: {
ok: true,
ok: Boolean(runtime.secrets.uploadApiKey || env.SCANNER_MAP_UPLOAD_API_KEY),
url: `/api/call-upload`
}
};
Expand Down
52 changes: 52 additions & 0 deletions test/settingsService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ const assert = require('node:assert/strict');
const {
decryptSecret,
encryptSecret,
getRuntimeConfig,
getRuntimeSecret,
getRuntimeSetting,
getSetupStatus,
resolveSettings
} = require('../src/settings/settingsService');
Expand All @@ -14,6 +17,10 @@ function createFakeDb({ settingsRows = [], setupComplete = false, adminCount = 0
callback(null, settingsRows);
},
get(sql, params, callback) {
if (sql.includes('app_settings')) {
callback(null, settingsRows.find((row) => row.key === params[0]));
return;
}
if (sql.includes('setup_state')) {
callback(null, setupComplete ? { value: 'true' } : undefined);
return;
Expand Down Expand Up @@ -47,6 +54,51 @@ test('resolveSettings prefers SQLite settings over env and defaults', async () =
assert.equal(resolved.settings.storageMode.source, 'default');
});

test('runtime setting reads SQLite before env before default', async () => {
const db = createFakeDb({
settingsRows: [
{ key: 'storageMode', value: 's3', is_secret: 0, requires_restart: 1, updated_at: 'now' }
]
});

assert.equal(await getRuntimeSetting(db, 'storageMode', { STORAGE_MODE: 'local' }), 's3');
assert.equal(await getRuntimeSetting(createFakeDb(), 'storageMode', { STORAGE_MODE: 'local' }), 'local');
assert.equal(await getRuntimeSetting(createFakeDb(), 'storageMode', {}), 'local');
});

test('runtime secret decrypts SQLite values before env fallback', async () => {
const secret = 'test-instance-secret';
const encrypted = encryptSecret('stored-openai-key', secret);
const db = createFakeDb({
settingsRows: [
{ key: 'openaiApiKey', value: encrypted, is_secret: 1, requires_restart: 0, updated_at: 'now' }
]
});

const value = await getRuntimeSecret(db, 'openaiApiKey', {
env: {
SETTINGS_ENCRYPTION_KEY: secret,
OPENAI_API_KEY: 'env-openai-key'
}
});

assert.equal(value, 'stored-openai-key');
assert.equal(await getRuntimeSecret(createFakeDb(), 'openaiApiKey', { env: { OPENAI_API_KEY: 'env-key' } }), 'env-key');
});

test('runtime config includes resolved settings and decrypted secrets', async () => {
const db = createFakeDb({
settingsRows: [
{ key: 'timezone', value: 'America/Chicago', is_secret: 0, requires_restart: 0, updated_at: 'now' }
]
});

const config = await getRuntimeConfig(db, { OPENAI_API_KEY: 'env-key' });

assert.equal(config.settings.timezone, 'America/Chicago');
assert.equal(config.secrets.openaiApiKey, 'env-key');
});

test('resolveSettings redacts write-only secret values', async () => {
const db = createFakeDb({
settingsRows: [
Expand Down
56 changes: 55 additions & 1 deletion test/setupChecks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

const { checkWritableDir } = require('../src/setup/checks');
const { checkWritableDir, runSetupChecks } = require('../src/setup/checks');

test('checkWritableDir creates and verifies writable directories', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-map-check-'));
Expand All @@ -16,3 +16,57 @@ test('checkWritableDir creates and verifies writable directories', () => {
assert.equal(fs.existsSync(nested), true);
fs.rmSync(tempDir, { recursive: true, force: true });
});

test('runSetupChecks validates provider-specific readiness from runtime config', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-map-check-'));

const checks = await runSetupChecks({
rootDir: tempDir,
env: {},
runtime: {
settings: {
storageMode: 's3',
transcriptionMode: 'remote',
aiProvider: 'openai'
},
secrets: {}
}
});

assert.equal(checks.storageProvider.ok, false);
assert.equal(checks.transcriptionProvider.ok, false);
assert.equal(checks.aiProvider.ok, false);
assert.equal(checks.uploadEndpoint.ok, false);
fs.rmSync(tempDir, { recursive: true, force: true });
});

test('runSetupChecks accepts configured S3 and provider secrets', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-map-check-'));

const checks = await runSetupChecks({
rootDir: tempDir,
env: {},
runtime: {
settings: {
storageMode: 's3',
s3Endpoint: 'http://localhost:9000',
s3BucketName: 'scanner-audio',
transcriptionMode: 'remote',
fasterWhisperServerUrl: 'http://localhost:8000',
aiProvider: 'openai'
},
secrets: {
s3AccessKeyId: 'key',
s3SecretAccessKey: 'secret',
openaiApiKey: 'openai',
uploadApiKey: 'upload'
}
}
});

assert.equal(checks.storageProvider.ok, true);
assert.equal(checks.transcriptionProvider.ok, true);
assert.equal(checks.aiProvider.ok, true);
assert.equal(checks.uploadEndpoint.ok, true);
fs.rmSync(tempDir, { recursive: true, force: true });
});
Loading
Loading