feat(security): encrypt secrets and centralize app state

Store Twitch and Discord secrets as versioned safeStorage ciphertext behind explicit trusted IPC. Migrate legacy JSON transactionally into authoritative SQLite config and ordered queue repositories with rollback-safe markers and sanitized backups. Redact config exports and update renderer and release-harness contracts for secret-free config responses.
This commit is contained in:
Sucukdeluxe
2026-08-11 23:44:20 +02:00
parent 6147c9b812
commit 47be523b41
21 changed files with 824 additions and 252 deletions
+68
View File
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { openDatabase, type DbHandle } from '../infra/db';
import { createAppStateStore } from './app-state-store';
let directory: string;
let databasePath: string;
let db: DbHandle;
beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'app-state-'));
databasePath = path.join(directory, 'app.db');
db = openDatabase(databasePath);
});
afterEach(() => {
db.close();
fs.rmSync(directory, { recursive: true, force: true });
});
describe('createAppStateStore', () => {
it('recovers configuration from SQLite after restart without secret fields', () => {
const store = createAppStateStore(db);
store.saveConfig({
language: 'de',
client_id: 'client-id',
client_secret: 'plain-client-secret',
discord_webhook_url: 'https://discord.com/api/webhooks/plain',
downloaded_vod_ids: ['v2', 'v1'],
auto_record_streamers: ['Alice'],
auto_vod_download_streamers: ['Bob'],
});
db.close();
db = openDatabase(databasePath);
const recovered = createAppStateStore(db).loadConfig();
expect(recovered).toMatchObject({
language: 'de',
client_id: 'client-id',
downloaded_vod_ids: ['v2', 'v1'],
auto_record_streamers: ['alice'],
auto_vod_download_streamers: ['bob'],
});
expect(recovered).not.toHaveProperty('client_secret');
expect(recovered).not.toHaveProperty('discord_webhook_url');
const persisted = JSON.stringify(db.all('SELECT key, value FROM config_kv'));
expect(persisted).not.toContain('plain-client-secret');
expect(persisted).not.toContain('/webhooks/plain');
});
it('persists queue snapshots atomically and preserves order across restart', () => {
const first = { id: 'q2', status: 'pending', title: 'second', streamer: 'Beta' };
const second = { id: 'q1', status: 'completed', title: 'first', streamer: 'Alpha' };
createAppStateStore(db).saveQueue([first, second]);
db.close();
db = openDatabase(databasePath);
expect(createAppStateStore(db).loadQueue()).toEqual([first, second]);
expect(db.all<{ id: string; queue_position: number }>('SELECT id, queue_position FROM queue_items ORDER BY queue_position')).toEqual([
{ id: 'q2', queue_position: 0 },
{ id: 'q1', queue_position: 1 },
]);
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { DbHandle } from '../infra/db';
import { normalizeLogin } from './config-normalize';
export interface AppStateStore {
loadConfig(): Record<string, unknown>;
saveConfig<T extends object>(config: T): void;
loadQueue<T extends object = Record<string, unknown>>(): T[];
saveQueue<T extends object>(queue: T[]): void;
}
const SECRET_CONFIG_KEYS = new Set(['client_secret', 'discord_webhook_url']);
function normalizedLogins(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value
.filter((entry): entry is string => typeof entry === 'string')
.map(normalizeLogin)
.filter(Boolean))];
}
function stringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))];
}
function normalizeConfig(config: object): Record<string, unknown> {
const source = config as Record<string, unknown>;
const normalized = Object.fromEntries(
Object.entries(source).filter(([key]) => !SECRET_CONFIG_KEYS.has(key))
);
normalized.downloaded_vod_ids = stringArray(source.downloaded_vod_ids);
normalized.auto_record_streamers = normalizedLogins(source.auto_record_streamers);
normalized.auto_vod_download_streamers = normalizedLogins(source.auto_vod_download_streamers);
return normalized;
}
export function createAppStateStore(db: DbHandle): AppStateStore {
return {
loadConfig() {
return Object.fromEntries(
db.all<{ key: string; value: string }>('SELECT key, value FROM config_kv')
.map((row) => [row.key, JSON.parse(row.value)])
);
},
saveConfig(config) {
const normalized = normalizeConfig(config);
db.transaction(() => {
db.run('DELETE FROM config_kv');
for (const [key, value] of Object.entries(normalized)) {
db.run(
`INSERT INTO config_kv(key, value, updated_at)
VALUES (?, ?, strftime('%s','now'))`,
[key, JSON.stringify(value)]
);
}
db.run('DELETE FROM downloaded_vods');
for (const vodId of normalized.downloaded_vod_ids as string[]) {
db.run('INSERT INTO downloaded_vods(vod_id) VALUES (?)', [vodId]);
}
db.run('DELETE FROM streamers');
for (const login of normalized.auto_record_streamers as string[]) {
db.run('INSERT INTO streamers(login, auto_record) VALUES (?, 1)', [login]);
}
for (const login of normalized.auto_vod_download_streamers as string[]) {
db.run(
`INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1)
ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1`,
[login]
);
}
});
},
loadQueue<T extends object>() {
return db.all<{ payload_json: string }>(
'SELECT payload_json FROM queue_items ORDER BY queue_position, created_at, id'
).map((row) => JSON.parse(row.payload_json) as T);
},
saveQueue<T extends object>(queue: T[]) {
const now = Math.floor(Date.now() / 1000);
db.transaction(() => {
db.run('DELETE FROM queue_items');
queue.forEach((rawItem, index) => {
const item = rawItem as Record<string, unknown>;
const id = typeof item.id === 'string' && item.id ? item.id : null;
if (!id) throw new Error('Queue item id must not be empty');
db.run(
`INSERT OR REPLACE INTO queue_items
(id, queue_position, streamer_login, vod_id, clip_id, title, output_path, status,
progress_pct, error_message, created_at, updated_at, completed_at, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
index,
typeof item.streamer === 'string' ? normalizeLogin(item.streamer) : null,
typeof item.vod_id === 'string' ? item.vod_id : null,
typeof item.clip_id === 'string' ? item.clip_id : null,
typeof item.title === 'string' ? item.title : null,
typeof item.output_path === 'string' ? item.output_path : null,
typeof item.status === 'string' ? item.status : 'pending',
typeof item.progress_pct === 'number' ? item.progress_pct : null,
typeof item.error_message === 'string' ? item.error_message : null,
typeof item.created_at === 'number' ? item.created_at : now,
typeof item.updated_at === 'number' ? item.updated_at : now,
typeof item.completed_at === 'number' ? item.completed_at : null,
JSON.stringify(item),
]
);
});
});
},
};
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { createExportableConfig } from './config-export';
describe('createExportableConfig', () => {
it('recursively removes every secret-bearing field and value', () => {
const exported = createExportableConfig({
language: 'de',
client_secret: 'client-secret-value',
discord_webhook_url: 'https://discord.com/api/webhooks/value',
access_token: 'access-token-value',
nested: {
refresh_token: 'refresh-token-value',
password: 'password-value',
cookie: 'cookie-value',
},
}, new Date('2026-08-11T20:00:00.000Z'));
const serialized = JSON.stringify(exported);
expect(exported).toMatchObject({ language: 'de', __exportVersion: 2, __exportedAt: '2026-08-11T20:00:00.000Z' });
for (const forbidden of ['client_secret', 'discord_webhook_url', 'access_token', 'refresh_token', 'password', 'cookie', 'client-secret-value', '/webhooks/value']) {
expect(serialized).not.toContain(forbidden);
}
});
});
+20
View File
@@ -0,0 +1,20 @@
const SECRET_KEYS = /(^|_)(authorization|cookie|password|secret|token)($|_)/i;
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact);
if (!value || typeof value !== 'object') return value;
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (SECRET_KEYS.test(key) || key.toLowerCase() === 'discord_webhook_url') continue;
result[key] = redact(entry);
}
return result;
}
export function createExportableConfig(config: Record<string, unknown>, exportedAt = new Date()): Record<string, unknown> {
return {
...(redact(config) as Record<string, unknown>),
__exportVersion: 2,
__exportedAt: exportedAt.toISOString(),
};
}
+120 -2
View File
@@ -4,6 +4,8 @@ import * as os from 'os';
import * as path from 'path';
import { openDatabase, type DbHandle } from '../infra/db';
import { migrateJsonToSqlite } from './migrator';
import { MemorySecureStorage } from '../infra/secure-storage';
import { createSecretStore } from './secret-store';
let tmpDir: string;
let appDataDir: string;
@@ -33,8 +35,8 @@ describe('migrateJsonToSqlite', () => {
expect(result.downloadedVodsCount).toBe(0);
expect(result.streamersCount).toBe(0);
const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['v4-to-v5-jsons']);
expect(marker?.name).toBe('v4-to-v5-jsons');
const marker = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1']);
expect(marker?.name).toBe('authoritative-state-v1');
});
test('migrates config.json keys into config_kv', () => {
@@ -118,5 +120,121 @@ describe('migrateJsonToSqlite', () => {
expect(result.configMigrated).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0].source).toBe('config.json');
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
});
test('rolls back all records and marker when migration is interrupted', () => {
writeJson('config.json', { language: 'de', metadata_cache_minutes: 30 });
writeJson('download_queue.json', [{ id: 'q1', status: 'pending', title: 'Queue item' }]);
let queueInsertReached = false;
const interruptedDb: DbHandle = {
...db,
run(sql, params) {
if (sql.includes('INSERT OR REPLACE INTO queue_items')) {
queueInsertReached = true;
throw new Error('simulated interruption');
}
db.run(sql, params);
},
};
const result = migrateJsonToSqlite({ db: interruptedDb, appDataDir });
expect(queueInsertReached).toBe(true);
expect(result.errors).toEqual([{ source: 'migration', message: 'simulated interruption' }]);
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
expect(db.all('SELECT * FROM queue_items')).toEqual([]);
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
});
test('rolls back config queue and secrets when the migration marker cannot be written', () => {
const configPath = writeJson('config.json', { language: 'de', client_secret: 'must-survive' });
writeJson('download_queue.json', [{ id: 'q1', status: 'pending' }]);
const interruptedDb: DbHandle = {
...db,
run(sql, params) {
if (sql.includes('INSERT INTO migrations_applied')) throw new Error('marker unavailable');
db.run(sql, params);
},
};
const secrets = createSecretStore(interruptedDb, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db: interruptedDb, appDataDir, secrets });
expect(result.errors).toEqual([{ source: 'migration', message: 'marker unavailable' }]);
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
expect(db.all('SELECT * FROM queue_items')).toEqual([]);
expect(db.all('SELECT * FROM app_secrets')).toEqual([]);
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
});
test('imports legacy JSON once and ignores later JSON changes after restart', () => {
const configPath = writeJson('config.json', { language: 'de' });
migrateJsonToSqlite({ db, appDataDir });
fs.writeFileSync(configPath, JSON.stringify({ language: 'en' }), 'utf-8');
const second = migrateJsonToSqlite({ db, appDataDir });
const language = db.get<{ value: string }>('SELECT value FROM config_kv WHERE key = ?', ['language']);
expect(second.alreadyApplied).toBe(true);
expect(JSON.parse(language!.value)).toBe('de');
});
test('does not let the former shadow-migration marker skip authoritative secret import', () => {
const configPath = writeJson('config.json', { language: 'de', client_secret: 'legacy-secret' });
db.run('INSERT INTO migrations_applied(name, payload) VALUES (?, ?)', ['v4-to-v5-jsons', '{}']);
const secrets = createSecretStore(db, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
expect(result.alreadyApplied).toBe(false);
expect(result.errors).toEqual([]);
expect(secrets.get('twitch_client_secret')).toBe('legacy-secret');
expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('legacy-secret');
});
test('migrates plaintext secrets into encrypted versioned records and scrubs JSON', () => {
const configPath = writeJson('config.json', {
client_secret: 'legacy-client-secret',
discord_webhook_url: 'https://discord.com/api/webhooks/legacy',
language: 'de',
});
const secrets = createSecretStore(db, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
expect(result.errors).toEqual([]);
expect(secrets.get('twitch_client_secret')).toBe('legacy-client-secret');
expect(secrets.get('discord_webhook_url')).toBe('https://discord.com/api/webhooks/legacy');
expect(fs.readFileSync(configPath, 'utf-8')).not.toContain('legacy-client-secret');
expect(fs.readFileSync(configPath + '.v4-backup', 'utf-8')).not.toContain('/webhooks/legacy');
expect(db.get('SELECT key FROM config_kv WHERE key = ?', ['discord_webhook_url'])).toBeUndefined();
});
test('keeps plaintext legacy secrets untouched when production encryption is unavailable', () => {
const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' });
const secrets = createSecretStore(db, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db, appDataDir, secrets, requireEncryption: true });
expect(result.errors).toEqual([{ source: 'migration', message: 'OS secret encryption is unavailable' }]);
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
expect(secrets.get('twitch_client_secret')).toBeNull();
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
});
test('keeps plaintext legacy secrets when the sanitized backup cannot be published', () => {
const configPath = writeJson('config.json', { client_secret: 'must-survive', language: 'de' });
fs.mkdirSync(`${configPath}.v4-backup`);
const secrets = createSecretStore(db, new MemorySecureStorage());
const result = migrateJsonToSqlite({ db, appDataDir, secrets });
expect(result.errors).toHaveLength(1);
expect(fs.readFileSync(configPath, 'utf-8')).toContain('must-survive');
expect(db.all('SELECT * FROM config_kv')).toEqual([]);
expect(db.all('SELECT * FROM app_secrets')).toEqual([]);
expect(db.get('SELECT name FROM migrations_applied WHERE name = ?', ['authoritative-state-v1'])).toBeUndefined();
});
});
+121 -163
View File
@@ -1,11 +1,14 @@
import * as fs from 'fs';
import * as path from 'path';
import type { DbHandle } from '../infra/db';
import { normalizeLogin } from './config-normalize';
import { createAppStateStore } from './app-state-store';
import type { SecretStore } from './secret-store';
export interface MigratorOptions {
db: DbHandle;
appDataDir: string;
secrets?: SecretStore;
requireEncryption?: boolean;
}
export interface MigrationError {
@@ -22,180 +25,135 @@ export interface MigrationResult {
errors: MigrationError[];
}
const MIGRATION_NAME = 'v4-to-v5-jsons';
const MIGRATION_NAME = 'authoritative-state-v1';
const SECRET_KEYS = new Set(['client_secret', 'discord_webhook_url']);
const CONFIG_KV_KEYS = [
'language', 'performance_mode', 'metadata_cache_minutes', 'streamlink_quality',
'streamlink_disable_ads', 'download_chat_replay', 'capture_live_chat',
'discord_webhook_url', 'discord_notify_live_start', 'discord_notify_live_end',
'discord_notify_vod_complete', 'discord_notify_vod_auto_queued',
'auto_cleanup_enabled', 'auto_cleanup_days', 'auto_cleanup_target',
'auto_cleanup_action', 'log_stream_events', 'auto_vod_download_poll_minutes',
'auto_vod_max_age_hours', 'auto_resume_live_recording',
'auto_merge_resumed_parts', 'delete_parts_after_merge',
'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts',
'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads',
'persist_queue_on_restart', 'auto_resume_queue_on_startup',
'notify_on_each_completion', 'sidebar_split_view',
] as const;
function backupOnce(srcPath: string): void {
const backupPath = srcPath + '.v4-backup';
if (!fs.existsSync(backupPath)) {
fs.copyFileSync(srcPath, backupPath);
}
}
function migrateConfig(db: DbHandle, configPath: string, errors: MigrationError[]): { ok: boolean; vodCount: number } {
function readJson<T>(filePath: string, source: string, errors: MigrationError[]): T | undefined {
if (!fs.existsSync(filePath)) return undefined;
try {
const raw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(raw) as Record<string, unknown>;
let vodCount = 0;
db.transaction(() => {
for (const key of CONFIG_KV_KEYS) {
if (key in config) {
db.run(
"INSERT OR REPLACE INTO config_kv(key, value, updated_at) VALUES (?, ?, strftime('%s','now'))",
[key, JSON.stringify(config[key])]
);
}
}
const vodIds = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids : [];
for (const id of vodIds) {
if (typeof id !== 'string' || !id) continue;
db.run('INSERT OR IGNORE INTO downloaded_vods(vod_id) VALUES (?)', [id]);
vodCount += 1;
}
const autoRec = Array.isArray(config.auto_record_streamers) ? config.auto_record_streamers : [];
for (const s of autoRec) {
if (typeof s !== 'string' || !s) continue;
const login = normalizeLogin(s);
if (!login) continue;
db.run(
'INSERT INTO streamers(login, auto_record) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_record = 1',
[login]
);
}
const autoDl = Array.isArray(config.auto_vod_download_streamers) ? config.auto_vod_download_streamers : [];
for (const s of autoDl) {
if (typeof s !== 'string' || !s) continue;
const login = normalizeLogin(s);
if (!login) continue;
db.run(
'INSERT INTO streamers(login, auto_vod_download) VALUES (?, 1) ON CONFLICT(login) DO UPDATE SET auto_vod_download = 1',
[login]
);
}
});
backupOnce(configPath);
return { ok: true, vodCount };
} catch (e) {
errors.push({ source: 'config.json', message: e instanceof Error ? e.message : String(e) });
return { ok: false, vodCount: 0 };
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T;
} catch (error) {
errors.push({ source, message: error instanceof Error ? error.message : String(error) });
return undefined;
}
}
function migrateQueue(db: DbHandle, queuePath: string, errors: MigrationError[]): boolean {
try {
const raw = fs.readFileSync(queuePath, 'utf-8');
const queue = JSON.parse(raw);
if (!Array.isArray(queue)) return false;
const now = Math.floor(Date.now() / 1000);
db.transaction(() => {
for (const rawItem of queue) {
if (!rawItem || typeof rawItem !== 'object') continue;
const item = rawItem as Record<string, unknown>;
const id = typeof item.id === 'string' ? item.id : null;
if (!id) continue;
db.run(
`INSERT OR REPLACE INTO queue_items
(id, streamer_login, vod_id, clip_id, title, output_path, status,
progress_pct, error_message, created_at, updated_at, completed_at, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
typeof item.streamer === 'string' ? normalizeLogin(item.streamer) : null,
typeof item.vod_id === 'string' ? item.vod_id : null,
typeof item.clip_id === 'string' ? item.clip_id : null,
typeof item.title === 'string' ? item.title : null,
typeof item.output_path === 'string' ? item.output_path : null,
typeof item.status === 'string' ? item.status : 'pending',
typeof item.progress_pct === 'number' ? item.progress_pct : null,
typeof item.error_message === 'string' ? item.error_message : null,
typeof item.created_at === 'number' ? item.created_at : now,
typeof item.updated_at === 'number' ? item.updated_at : now,
typeof item.completed_at === 'number' ? item.completed_at : null,
JSON.stringify(item),
]
);
}
});
backupOnce(queuePath);
return true;
} catch (e) {
errors.push({ source: 'download_queue.json', message: e instanceof Error ? e.message : String(e) });
return false;
}
function withoutSecrets(config: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(config).filter(([key]) => !SECRET_KEYS.has(key)));
}
export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
const { db, appDataDir } = opts;
const errors: MigrationError[] = [];
function writeJsonAtomic(filePath: string, value: unknown): void {
const temporaryPath = `${filePath}.${process.pid}.tmp`;
fs.writeFileSync(temporaryPath, JSON.stringify(value, null, 2), 'utf-8');
fs.renameSync(temporaryPath, filePath);
}
const existing = db.get<{ name: string }>(
'SELECT name FROM migrations_applied WHERE name = ?',
[MIGRATION_NAME]
);
if (existing) {
return {
alreadyApplied: true,
configMigrated: false,
queueMigrated: false,
downloadedVodsCount: 0,
streamersCount: 0,
errors: [],
};
}
function backupJson(filePath: string, value: unknown): void {
const backupPath = `${filePath}.v4-backup`;
if (!fs.existsSync(backupPath)) writeJsonAtomic(backupPath, value);
}
let configMigrated = false;
let queueMigrated = false;
let downloadedVodsCount = 0;
function scrubConfigFiles(configPath: string, config: Record<string, unknown>): void {
const sanitized = withoutSecrets(config);
writeJsonAtomic(`${configPath}.v4-backup`, sanitized);
writeJsonAtomic(configPath, sanitized);
}
const configPath = path.join(appDataDir, 'config.json');
if (fs.existsSync(configPath)) {
const r = migrateConfig(db, configPath, errors);
configMigrated = r.ok;
downloadedVodsCount = r.vodCount;
}
const queuePath = path.join(appDataDir, 'download_queue.json');
if (fs.existsSync(queuePath)) {
queueMigrated = migrateQueue(db, queuePath, errors);
}
const streamersCount = db.get<{ c: number }>('SELECT COUNT(*) AS c FROM streamers')?.c ?? 0;
db.run(
'INSERT INTO migrations_applied(name, payload) VALUES (?, ?)',
[
MIGRATION_NAME,
JSON.stringify({ configMigrated, queueMigrated, downloadedVodsCount, streamersCount, errorCount: errors.length }),
]
);
function scrubExistingConfig(configPath: string): void {
if (!fs.existsSync(configPath)) return;
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
scrubConfigFiles(configPath, config);
}
function emptyResult(alreadyApplied: boolean, errors: MigrationError[] = []): MigrationResult {
return {
alreadyApplied: false,
configMigrated,
queueMigrated,
downloadedVodsCount,
streamersCount,
alreadyApplied,
configMigrated: false,
queueMigrated: false,
downloadedVodsCount: 0,
streamersCount: 0,
errors,
};
}
export function migrateJsonToSqlite(opts: MigratorOptions): MigrationResult {
const { db, appDataDir, secrets, requireEncryption = false } = opts;
const configPath = path.join(appDataDir, 'config.json');
const queuePath = path.join(appDataDir, 'download_queue.json');
const existing = db.get<{ name: string }>('SELECT name FROM migrations_applied WHERE name = ?', [MIGRATION_NAME]);
if (existing) {
try {
scrubExistingConfig(configPath);
} catch (error) {
return emptyResult(true, [{ source: 'config.json', message: error instanceof Error ? error.message : String(error) }]);
}
return emptyResult(true);
}
const errors: MigrationError[] = [];
const configExists = fs.existsSync(configPath);
const queueExists = fs.existsSync(queuePath);
const config = readJson<Record<string, unknown>>(configPath, 'config.json', errors);
const queue = readJson<unknown>(queuePath, 'download_queue.json', errors);
if (queueExists && !Array.isArray(queue)) {
errors.push({ source: 'download_queue.json', message: 'Queue JSON must be an array' });
}
if (errors.length > 0) return emptyResult(false, errors);
if (config && (typeof config !== 'object' || Array.isArray(config))) {
return emptyResult(false, [{ source: 'config.json', message: 'Config JSON must be an object' }]);
}
if (config && !secrets && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key])) {
return emptyResult(false, [{ source: 'migration', message: 'Secure secret storage is required for plaintext secret migration' }]);
}
if (config && requireEncryption && [...SECRET_KEYS].some((key) => typeof config[key] === 'string' && config[key]) && !secrets?.status().encryptionAvailable) {
return emptyResult(false, [{ source: 'migration', message: 'OS secret encryption is unavailable' }]);
}
const state = createAppStateStore(db);
let downloadedVodsCount = 0;
let streamersCount = 0;
let configScrubbed = false;
try {
db.transaction(() => {
if (configExists && config) {
state.saveConfig(config);
downloadedVodsCount = Array.isArray(config.downloaded_vod_ids)
? config.downloaded_vod_ids.filter((value) => typeof value === 'string' && value).length
: 0;
if (typeof config.client_secret === 'string' && config.client_secret) {
secrets!.set('twitch_client_secret', config.client_secret);
}
if (typeof config.discord_webhook_url === 'string' && config.discord_webhook_url) {
secrets!.set('discord_webhook_url', config.discord_webhook_url);
}
}
if (queueExists) state.saveQueue(queue as Array<Record<string, unknown>>);
streamersCount = db.get<{ count: number }>('SELECT COUNT(*) AS count FROM streamers')?.count ?? 0;
db.run(
'INSERT INTO migrations_applied(name, payload) VALUES (?, ?)',
[MIGRATION_NAME, JSON.stringify({ configMigrated: configExists, queueMigrated: queueExists, downloadedVodsCount, streamersCount })]
);
if (queueExists) backupJson(queuePath, queue);
if (configExists && config) {
scrubConfigFiles(configPath, config);
configScrubbed = true;
}
});
} catch (error) {
if (configScrubbed && config) {
try {
writeJsonAtomic(configPath, config);
} catch { }
}
return emptyResult(false, [{ source: 'migration', message: error instanceof Error ? error.message : String(error) }]);
}
return {
alreadyApplied: false,
configMigrated: configExists,
queueMigrated: queueExists,
downloadedVodsCount,
streamersCount,
errors: [],
};
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { resolveSecretInputUpdate } from './secret-input';
describe('resolveSecretInputUpdate', () => {
it('keeps a configured secret when the masked value is unchanged', () => {
expect(resolveSecretInputUpdate('••••••••', true)).toEqual({ action: 'unchanged' });
});
it('clears a configured secret when the field is emptied', () => {
expect(resolveSecretInputUpdate(' ', true)).toEqual({ action: 'clear' });
});
it('sets a trimmed replacement without exposing the stored value', () => {
expect(resolveSecretInputUpdate(' replacement ', true)).toEqual({ action: 'set', value: 'replacement' });
});
it('ignores an empty field when no secret is configured', () => {
expect(resolveSecretInputUpdate('', false)).toEqual({ action: 'unchanged' });
});
});
+13
View File
@@ -0,0 +1,13 @@
export type SecretInputUpdate =
| { action: 'unchanged' }
| { action: 'clear' }
| { action: 'set'; value: string };
export const SECRET_INPUT_MASK = '••••••••';
export function resolveSecretInputUpdate(value: string, configured: boolean): SecretInputUpdate {
if (configured && value === SECRET_INPUT_MASK) return { action: 'unchanged' };
const normalized = value.trim();
if (normalized) return { action: 'set', value: normalized };
return configured ? { action: 'clear' } : { action: 'unchanged' };
}
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { openDatabase, type DbHandle } from '../infra/db';
import { MemorySecureStorage } from '../infra/secure-storage';
import { createSecretStore } from './secret-store';
let directory: string;
let db: DbHandle;
beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'app-secrets-'));
db = openDatabase(path.join(directory, 'app.db'));
});
afterEach(() => {
db.close();
fs.rmSync(directory, { recursive: true, force: true });
});
describe('createSecretStore', () => {
it('stores versioned ciphertext and exposes only configured status', () => {
const store = createSecretStore(db, new MemorySecureStorage());
store.set('twitch_client_secret', 'client-secret-value');
store.set('discord_webhook_url', 'https://discord.com/api/webhooks/value');
expect(store.get('twitch_client_secret')).toBe('client-secret-value');
expect(store.status()).toEqual({
encryptionAvailable: false,
clientSecretConfigured: true,
discordWebhookConfigured: true,
});
const rows = db.all<{ key: string; version: number; encrypted_value: string }>('SELECT key, version, encrypted_value FROM app_secrets ORDER BY key');
expect(rows.map((row) => row.version)).toEqual([1, 1]);
expect(JSON.stringify(rows)).not.toContain('client-secret-value');
expect(JSON.stringify(rows)).not.toContain('/webhooks/value');
});
it('clears one secret without changing the other', () => {
const store = createSecretStore(db, new MemorySecureStorage());
store.set('twitch_client_secret', 'client');
store.set('discord_webhook_url', 'webhook');
store.clear('twitch_client_secret');
expect(store.get('twitch_client_secret')).toBeNull();
expect(store.get('discord_webhook_url')).toBe('webhook');
});
});
+58
View File
@@ -0,0 +1,58 @@
import type { DbHandle } from '../infra/db';
import type { SecureStorage } from '../infra/secure-storage';
export type AppSecretKey = 'twitch_client_secret' | 'discord_webhook_url';
export interface SecretStatus {
encryptionAvailable: boolean;
clientSecretConfigured: boolean;
discordWebhookConfigured: boolean;
}
export interface SecretStore {
get(key: AppSecretKey): string | null;
set(key: AppSecretKey, value: string): void;
clear(key: AppSecretKey): void;
status(): SecretStatus;
}
const SECRET_VERSION = 1;
export function createSecretStore(db: DbHandle, storage: SecureStorage): SecretStore {
return {
get(key) {
const row = db.get<{ version: number; encrypted_value: string }>(
'SELECT version, encrypted_value FROM app_secrets WHERE key = ?',
[key]
);
if (!row) return null;
if (row.version !== SECRET_VERSION) {
throw new Error(`Unsupported secret record version: ${row.version}`);
}
return storage.decrypt(row.encrypted_value);
},
set(key, value) {
if (!value) throw new Error('Secret value must not be empty');
db.run(
`INSERT INTO app_secrets(key, version, encrypted_value, updated_at)
VALUES (?, ?, ?, strftime('%s','now'))
ON CONFLICT(key) DO UPDATE SET
version = excluded.version,
encrypted_value = excluded.encrypted_value,
updated_at = excluded.updated_at`,
[key, SECRET_VERSION, storage.encrypt(value)]
);
},
clear(key) {
db.run('DELETE FROM app_secrets WHERE key = ?', [key]);
},
status() {
const keys = new Set(db.all<{ key: AppSecretKey }>('SELECT key FROM app_secrets').map((row) => row.key));
return {
encryptionAvailable: storage.isEncryptionAvailable(),
clientSecretConfigured: keys.has('twitch_client_secret'),
discordWebhookConfigured: keys.has('discord_webhook_url'),
};
},
};
}