refactor(server): migrate 5 more services to TypeScript (.cts)

webauthnService, cleanupService, backupService, bankSyncService, authService.

Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 11:06:05 -05:00
parent 5fbc793bcb
commit 6746d42380
19 changed files with 163 additions and 206 deletions

View File

@ -1,5 +1,5 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { getSessionUser, COOKIE_NAME, SINGLE_COOKIE_NAME, cookieOpts, publicUser, recordLogin } = require('../services/authService'); const { getSessionUser, COOKIE_NAME, SINGLE_COOKIE_NAME, cookieOpts, publicUser, recordLogin } = require('../services/authService.cts');
const { getDb, getSetting } = require('../db/database'); const { getDb, getSetting } = require('../db/database');
const { standardizeError } = require('./errorFormatter'); const { standardizeError } = require('./errorFormatter');

View File

@ -4,7 +4,7 @@ const { getDb, rollbackMigration } = require('../db/database');
const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService.cts'); const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService.cts');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker.cts'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker.cts');
const { isEnvKeyActive, keyFingerprint } = require('../services/encryptionService.cts'); const { isEnvKeyActive, keyFingerprint } = require('../services/encryptionService.cts');
const { hashPassword } = require('../services/authService'); const { hashPassword } = require('../services/authService.cts');
const { logAudit } = require('../services/auditService.cts'); const { logAudit } = require('../services/auditService.cts');
const { const {
createBackup, createBackup,
@ -13,7 +13,7 @@ const {
importBackupBuffer, importBackupBuffer,
listBackups, listBackups,
restoreBackup, restoreBackup,
} = require('../services/backupService'); } = require('../services/backupService.cts');
const { const {
getScheduleStatus, getScheduleStatus,
runScheduledBackupNow, runScheduledBackupNow,
@ -23,7 +23,7 @@ const {
getCleanupStatus, getCleanupStatus,
runAllCleanup, runAllCleanup,
validateAndApplySettings: applyCleanupSettings, validateAndApplySettings: applyCleanupSettings,
} = require('../services/cleanupService'); } = require('../services/cleanupService.cts');
const { backupOperationLimiter } = require('../middleware/rateLimiter'); const { backupOperationLimiter } = require('../middleware/rateLimiter');
// All routes mounted at /api/admin (requireAuth + requireAdmin applied at server level) // All routes mounted at /api/admin (requireAuth + requireAdmin applied at server level)

View File

@ -10,7 +10,7 @@ function getAppVersion() {
} }
const { getDb, getSetting, setSetting } = require('../db/database'); const { getDb, getSetting, setSetting } = require('../db/database');
const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, SINGLE_COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin } = require('../services/authService'); const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, SINGLE_COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin } = require('../services/authService.cts');
const { decryptSecret } = require('../services/encryptionService.cts'); const { decryptSecret } = require('../services/encryptionService.cts');
const { getCsrfToken } = require('../middleware/csrf'); const { getCsrfToken } = require('../middleware/csrf');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
@ -205,7 +205,7 @@ router.post('/totp/challenge', async (req, res) => {
} }
try { try {
const { createSession } = require('../services/authService'); const { createSession } = require('../services/authService.cts');
const session = await createSession(userId); const session = await createSession(userId);
if (!session) return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR')); if (!session) return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
@ -437,7 +437,7 @@ const {
createRegistrationChallenge, verifyRegistration, createRegistrationChallenge, verifyRegistration,
createAuthenticationChallenge, verifyAuthentication, createAuthenticationChallenge, verifyAuthentication,
consumeLoginChallenge, getCredentials, deleteCredential, consumeLoginChallenge, getCredentials, deleteCredential,
} = require('../services/webauthnService'); } = require('../services/webauthnService.cts');
// GET /api/auth/webauthn/status // GET /api/auth/webauthn/status
router.get('/webauthn/status', requireAuth, (req, res) => { router.get('/webauthn/status', requireAuth, (req, res) => {
@ -570,7 +570,7 @@ router.post('/webauthn/challenge', async (req, res) => {
return res.status(401).json(standardizeError('Security key verification failed.', 'AUTH_ERROR')); return res.status(401).json(standardizeError('Security key verification failed.', 'AUTH_ERROR'));
} }
const { createSession } = require('../services/authService'); const { createSession } = require('../services/authService.cts');
const s = await createSession(session.userId); const s = await createSession(session.userId);
if (!s) return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR')); if (!s) return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));

View File

@ -13,7 +13,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { createSession, cookieOpts, COOKIE_NAME, recordLogin } = require('../services/authService'); const { createSession, cookieOpts, COOKIE_NAME, recordLogin } = require('../services/authService.cts');
const { const {
getOidcConfig, getOidcConfig,
isOidcLoginActive, isOidcLoginActive,

View File

@ -4,7 +4,7 @@ const router = require('express').Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService.cts'); const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService.cts');
const { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource } = require('../services/bankSyncService'); const { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource } = require('../services/bankSyncService.cts');
const { sanitizeErrorMessage } = require('../services/simplefinService.cts'); const { sanitizeErrorMessage } = require('../services/simplefinService.cts');
const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts'); const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts');
const { syncLimiter } = require('../middleware/rateLimiter'); const { syncLimiter } = require('../middleware/rateLimiter');

View File

@ -6,7 +6,7 @@ const bcrypt = require('bcryptjs');
const { passwordLimiter } = require('../middleware/rateLimiter'); const { passwordLimiter } = require('../middleware/rateLimiter');
const { getDb, getSetting } = require('../db/database'); const { getDb, getSetting } = require('../db/database');
const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, cookieOpts } = require('../services/authService'); const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, cookieOpts } = require('../services/authService.cts');
const { getImportHistory } = require('../services/spreadsheetImportService'); const { getImportHistory } = require('../services/spreadsheetImportService');
const { logAudit } = require('../services/auditService.cts'); const { logAudit } = require('../services/auditService.cts');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');

View File

@ -4,7 +4,7 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const { getDb, getSetting } = require('../db/database'); const { getDb, getSetting } = require('../db/database');
const { getStatusRuntime, recordError } = require('../services/statusRuntime.cts'); const { getStatusRuntime, recordError } = require('../services/statusRuntime.cts');
const { listBackups } = require('../services/backupService'); const { listBackups } = require('../services/backupService.cts');
const { getScheduleStatus } = require('../services/backupScheduler.cts'); const { getScheduleStatus } = require('../services/backupScheduler.cts');
const { checkForUpdates } = require('../services/updateCheckService.cts'); const { checkForUpdates } = require('../services/updateCheckService.cts');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker.cts'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker.cts');

View File

@ -1,3 +1,6 @@
import type { Db } from '../types/db';
import type { Req } from '../types/http';
const crypto = require('crypto'); const crypto = require('crypto');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
@ -5,7 +8,7 @@ const { buildDeviceFingerprint } = require('./loginFingerprint.cts');
const { encryptSecret, decryptSecret } = require('./encryptionService.cts'); const { encryptSecret, decryptSecret } = require('./encryptionService.cts');
// Store SHA-256(token) in the DB; the raw token stays only in the cookie. // Store SHA-256(token) in the DB; the raw token stays only in the cookie.
function hashSession(token) { function hashSession(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex'); return crypto.createHash('sha256').update(token).digest('hex');
} }
@ -14,18 +17,16 @@ const SINGLE_COOKIE_NAME = 'bt_single_session';
const SESSION_DAYS = 7; const SESSION_DAYS = 7;
// Pre-computed hash used to equalize login timing when the account is unknown, // Pre-computed hash used to equalize login timing when the account is unknown,
// inactive, or OIDC-only. Without it those paths skip bcrypt.compare and // inactive, or OIDC-only. Cost factor 12 matches real password hashes.
// respond measurably faster, letting an attacker enumerate valid usernames.
// Cost factor 12 matches real password hashes. Computed once at module load.
const TIMING_EQUALIZATION_HASH = bcrypt.hashSync(crypto.randomBytes(32).toString('hex'), 12); const TIMING_EQUALIZATION_HASH = bcrypt.hashSync(crypto.randomBytes(32).toString('hex'), 12);
function envFlag(name) { function envFlag(name: string): boolean | null {
const value = process.env[name]; const value = process.env[name];
if (value === undefined) return null; if (value === undefined) return null;
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase()); return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
} }
function requestLooksHttps(req) { function requestLooksHttps(req?: Req): boolean {
if (!req) return false; if (!req) return false;
if (req.secure) return true; if (req.secure) return true;
const proto = req.get?.('x-forwarded-proto') || req.headers?.['x-forwarded-proto']; const proto = req.get?.('x-forwarded-proto') || req.headers?.['x-forwarded-proto'];
@ -34,11 +35,10 @@ function requestLooksHttps(req) {
/** /**
* Build session-cookie options. * Build session-cookie options.
*
* COOKIE_SECURE=true/false is explicit. HTTPS=true keeps the old deployment knob. * COOKIE_SECURE=true/false is explicit. HTTPS=true keeps the old deployment knob.
* Otherwise, mark cookies Secure only when the current request appears to be HTTPS. * Otherwise, mark cookies Secure only when the current request appears to be HTTPS.
*/ */
function cookieOpts(req) { function cookieOpts(req?: Req) {
const cookieSecure = envFlag('COOKIE_SECURE'); const cookieSecure = envFlag('COOKIE_SECURE');
const httpsSecure = envFlag('HTTPS'); const httpsSecure = envFlag('HTTPS');
const secure = cookieSecure !== null const secure = cookieSecure !== null
@ -56,13 +56,12 @@ function cookieOpts(req) {
}; };
} }
async function login(username, password) { async function login(username: any, password: any) {
const db = getDb(); const db: Db = getDb();
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
// Unknown, inactive, or OIDC-only account: still burn a bcrypt comparison so // Unknown, inactive, or OIDC-only account: still burn a bcrypt comparison so
// the response time is indistinguishable from a wrong password (no timing // the response time is indistinguishable from a wrong password.
// oracle for username enumeration).
if (!user || user.active === 0 || (user.auth_provider && user.auth_provider !== 'local')) { if (!user || user.active === 0 || (user.auth_provider && user.auth_provider !== 'local')) {
await bcrypt.compare(password, TIMING_EQUALIZATION_HASH); await bcrypt.compare(password, TIMING_EQUALIZATION_HASH);
return null; return null;
@ -70,10 +69,9 @@ async function login(username, password) {
const valid = await bcrypt.compare(password, user.password_hash); const valid = await bcrypt.compare(password, user.password_hash);
// Return userId so the route can log the failed attempt against the known account. // Return userId so the route can log the failed attempt against the known account.
// The external response is identical either way — no user enumeration.
if (!valid) return { error: 'bad_password', userId: user.id }; if (!valid) return { error: 'bad_password', userId: user.id };
// TOTP is enabled — don't create a session yet; issue a short-lived challenge instead // TOTP is enabled — issue a short-lived challenge instead of a session
if (user.totp_enabled) { if (user.totp_enabled) {
const { createChallenge } = require('./totpService.cts'); const { createChallenge } = require('./totpService.cts');
const challengeToken = createChallenge(getDb(), user.id); const challengeToken = createChallenge(getDb(), user.id);
@ -82,7 +80,7 @@ async function login(username, password) {
// WebAuthn is enabled — issue a WebAuthn authentication challenge instead // WebAuthn is enabled — issue a WebAuthn authentication challenge instead
if (user.webauthn_enabled) { if (user.webauthn_enabled) {
const { createAuthenticationChallenge, createLoginChallenge } = require('./webauthnService'); const { createAuthenticationChallenge, createLoginChallenge } = require('./webauthnService.cts');
const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id); const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id);
const loginToken = createLoginChallenge(getDb(), user.id, challengeId); const loginToken = createLoginChallenge(getDb(), user.id, challengeId);
return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options }; return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options };
@ -91,7 +89,7 @@ async function login(username, password) {
// Clean up expired sessions for this user before creating new session // Clean up expired sessions for this user before creating new session
try { try {
db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(user.id); db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(user.id);
} catch (err) { } catch (err: any) {
console.error('[cleanup-error] Failed to cleanup user expired sessions:', err.message); console.error('[cleanup-error] Failed to cleanup user expired sessions:', err.message);
} }
@ -110,8 +108,8 @@ async function login(username, password) {
return { sessionId, user: publicUser(user) }; return { sessionId, user: publicUser(user) };
} }
async function createSession(userId) { async function createSession(userId: any) {
const db = getDb(); const db: Db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) return null; if (!user) return null;
if (user.active === 0) return null; if (user.active === 0) return null;
@ -119,7 +117,7 @@ async function createSession(userId) {
// Clean up expired sessions for this user before creating new session // Clean up expired sessions for this user before creating new session
try { try {
db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(userId); db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(userId);
} catch (err) { } catch (err: any) {
console.error('[cleanup-error] Failed to cleanup user expired sessions:', err.message); console.error('[cleanup-error] Failed to cleanup user expired sessions:', err.message);
} }
@ -133,19 +131,18 @@ async function createSession(userId) {
return { sessionId, user: publicUser(user) }; return { sessionId, user: publicUser(user) };
} }
function logout(sessionId) { function logout(sessionId: any): void {
if (!sessionId) return; if (!sessionId) return;
getDb().prepare('DELETE FROM sessions WHERE id = ?').run(hashSession(sessionId)); getDb().prepare('DELETE FROM sessions WHERE id = ?').run(hashSession(sessionId));
} }
/** /**
* Regenerate session ID for security (e.g., on privilege escalation). * Regenerate session ID for security (e.g., on privilege escalation).
* This invalidates the old session and creates a new one with the same user.
*/ */
function rotateSessionId(oldSessionId, userId) { function rotateSessionId(oldSessionId: any, userId: any): string | null {
if (!oldSessionId || !userId) return null; if (!oldSessionId || !userId) return null;
const db = getDb(); const db: Db = getDb();
// Verify the old session belongs to the user and is valid // Verify the old session belongs to the user and is valid
const existingSession = db.prepare('SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime(\'now\')').get(hashSession(oldSessionId)); const existingSession = db.prepare('SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime(\'now\')').get(hashSession(oldSessionId));
@ -172,7 +169,7 @@ function rotateSessionId(oldSessionId, userId) {
return newSessionId; return newSessionId;
} }
function getSessionUser(sessionId) { function getSessionUser(sessionId: any): any {
if (!sessionId) return null; if (!sessionId) return null;
const row = getDb().prepare(` const row = getDb().prepare(`
SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login, SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login,
@ -184,11 +181,11 @@ function getSessionUser(sessionId) {
return row || null; return row || null;
} }
async function hashPassword(password) { async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12); return bcrypt.hash(password, 12);
} }
function publicUser(u) { function publicUser(u: any) {
const displayName = u.display_name || null; const displayName = u.display_name || null;
return { return {
id: u.id, id: u.id,
@ -209,11 +206,9 @@ function publicUser(u) {
/** /**
* Records a successful login with encrypted IP/UA and prunes older entries * Records a successful login with encrypted IP/UA and prunes older entries
* so each user keeps at most 10 login history rows. * so each user keeps at most 10 login history rows.
* Session fingerprint is stored for current-session detection.
* New device alerts and geolocation are resolved asynchronously.
*/ */
function recordLogin(userId, ipAddress, userAgent, sessionId) { function recordLogin(userId: any, ipAddress: any, userAgent: any, sessionId: any): void {
const db = getDb(); const db: Db = getDb();
const device = buildDeviceFingerprint({ userAgent, ipAddress }); const device = buildDeviceFingerprint({ userAgent, ipAddress });
const encIp = ipAddress ? encryptSecret(ipAddress) : null; const encIp = ipAddress ? encryptSecret(ipAddress) : null;
@ -223,18 +218,18 @@ function recordLogin(userId, ipAddress, userAgent, sessionId) {
: null; : null;
// Collect prior fingerprints before insert to detect new devices // Collect prior fingerprints before insert to detect new devices
let priorFingerprints; let priorFingerprints: any[];
try { try {
priorFingerprints = db.prepare(` priorFingerprints = db.prepare(`
SELECT device_fingerprint FROM user_login_history SELECT device_fingerprint FROM user_login_history
WHERE user_id = ? AND success = 1 WHERE user_id = ? AND success = 1
ORDER BY logged_in_at DESC, id DESC LIMIT 10 ORDER BY logged_in_at DESC, id DESC LIMIT 10
`).all(userId).map(r => r.device_fingerprint); `).all(userId).map((r: any) => r.device_fingerprint);
} catch { } catch {
priorFingerprints = []; priorFingerprints = [];
} }
let insertedId; let insertedId: any;
db.transaction(() => { db.transaction(() => {
const result = db.prepare(` const result = db.prepare(`
INSERT INTO user_login_history ( INSERT INTO user_login_history (
@ -275,8 +270,8 @@ function recordLogin(userId, ipAddress, userAgent, sessionId) {
const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1|localhost)/i.test(ipAddress); const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1|localhost)/i.test(ipAddress);
if (!isPrivate) { if (!isPrivate) {
fetch(`http://ip-api.com/json/${ipAddress}?fields=status,city,country,regionName,isp`, { signal: AbortSignal.timeout(5000) }) fetch(`http://ip-api.com/json/${ipAddress}?fields=status,city,country,regionName,isp`, { signal: AbortSignal.timeout(5000) })
.then(r => r.json()) .then((r: Response) => r.json())
.then(data => { .then((data: any) => {
if (data.status !== 'success') return; if (data.status !== 'success') return;
const updDb = getDb(); const updDb = getDb();
updDb.prepare(` updDb.prepare(`
@ -321,14 +316,11 @@ function recordLogin(userId, ipAddress, userAgent, sessionId) {
/** /**
* Records a failed login attempt (wrong password for a known account). * Records a failed login attempt (wrong password for a known account).
* Stored with success=0 so it shows in login history but doesn't count as
* a real session. Only called when the username maps to a real user
* unknown usernames are not tracked to prevent user-enumeration side-channels.
*/ */
function recordFailedLogin(userId, ipAddress, userAgent) { function recordFailedLogin(userId: any, ipAddress: any, userAgent: any): void {
if (!userId) return; if (!userId) return;
try { try {
const db = getDb(); const db: Db = getDb();
const device = buildDeviceFingerprint({ userAgent, ipAddress }); const device = buildDeviceFingerprint({ userAgent, ipAddress });
const encIp = ipAddress ? encryptSecret(ipAddress) : null; const encIp = ipAddress ? encryptSecret(ipAddress) : null;
const encUa = userAgent ? encryptSecret(userAgent.slice(0, 500)) : null; const encUa = userAgent ? encryptSecret(userAgent.slice(0, 500)) : null;
@ -367,16 +359,13 @@ function pruneExpiredSessions() {
} }
/** /**
* Invalidate all sessions for a user except for a specific session ID * Invalidate all sessions for a user except for a specific session ID.
* @param {number} userId - User ID
* @param {string} keepSessionId - Session ID to keep (typically the current session)
* @returns {Object} Result object with changes count
*/ */
function invalidateOtherSessions(userId, keepSessionId) { function invalidateOtherSessions(userId: any, keepSessionId: any) {
if (!userId) return { changes: 0 }; if (!userId) return { changes: 0 };
const db = getDb(); const db: Db = getDb();
let result; let result: any;
if (keepSessionId) { if (keepSessionId) {
result = db.prepare( result = db.prepare(

View File

@ -3,7 +3,7 @@ import type { Db } from '../types/db';
const cron = require('node-cron'); const cron = require('node-cron');
const { getSetting, setSetting } = require('../db/database'); const { getSetting, setSetting } = require('../db/database');
const { applyScheduledRetention, createBackup } = require('./backupService'); const { applyScheduledRetention, createBackup } = require('./backupService.cts');
interface ScheduleSettings { interface ScheduleSettings {
enabled: boolean; enabled: boolean;

View File

@ -1,3 +1,6 @@
// import required so Node type-strips this .cts (it has no other import).
import type { Db } from '../types/db';
const crypto = require('crypto'); const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@ -11,22 +14,22 @@ const BACKUP_ID_RE = /^(?:bill-tracker-backup|pre-restore|imported-backup|schedu
// ─── Helper Functions ───────────────────────────────────────────────────────── // ─── Helper Functions ─────────────────────────────────────────────────────────
function ensureBackupDir() { function ensureBackupDir(): void {
fs.mkdirSync(BACKUP_DIR, { recursive: true, mode: 0o700 }); fs.mkdirSync(BACKUP_DIR, { recursive: true, mode: 0o700 });
} }
function timestampPart(date = new Date()) { function timestampPart(date: Date = new Date()): string {
return date.toISOString() return date.toISOString()
.replace(/[-:]/g, '') .replace(/[-:]/g, '')
.replace('T', '-') .replace('T', '-')
.replace(/\.\d{3}Z$/, `-${String(date.getMilliseconds()).padStart(3, '0')}Z`); .replace(/\.\d{3}Z$/, `-${String(date.getMilliseconds()).padStart(3, '0')}Z`);
} }
function makeBackupId(prefix = 'bill-tracker-backup') { function makeBackupId(prefix = 'bill-tracker-backup'): string {
return `${prefix}-${timestampPart()}-${crypto.randomBytes(4).toString('hex')}.sqlite`; return `${prefix}-${timestampPart()}-${crypto.randomBytes(4).toString('hex')}.sqlite`;
} }
function assertValidBackupId(id) { function assertValidBackupId(id: unknown): string {
if ( if (
typeof id !== 'string' || typeof id !== 'string' ||
id.includes('/') || id.includes('/') ||
@ -34,44 +37,35 @@ function assertValidBackupId(id) {
path.isAbsolute(id) || path.isAbsolute(id) ||
!BACKUP_ID_RE.test(id) !BACKUP_ID_RE.test(id)
) { ) {
const err = new Error('Invalid backup id'); const err = new Error('Invalid backup id') as any;
err.status = 400; err.status = 400;
throw err; throw err;
} }
return id; return id;
} }
function backupPathForId(id) { function backupPathForId(id: string): string {
assertValidBackupId(id); assertValidBackupId(id);
ensureBackupDir(); ensureBackupDir();
const resolved = path.resolve(BACKUP_DIR, id); const resolved = path.resolve(BACKUP_DIR, id);
const relative = path.relative(BACKUP_DIR, resolved); const relative = path.relative(BACKUP_DIR, resolved);
if (relative.startsWith('..') || path.isAbsolute(relative)) { if (relative.startsWith('..') || path.isAbsolute(relative)) {
const err = new Error('Invalid backup id'); const err = new Error('Invalid backup id') as any;
err.status = 400; err.status = 400;
throw err; throw err;
} }
return resolved; return resolved;
} }
/** /** Generates SHA-256 checksum for a file. */
* Generates SHA-256 checksum for a file. function checksumFile(filePath: string): string {
* @param {string} filePath - Path to the file
* @returns {string} - Hex-encoded SHA-256 hash
*/
function checksumFile(filePath) {
const hash = crypto.createHash('sha256'); const hash = crypto.createHash('sha256');
hash.update(fs.readFileSync(filePath)); hash.update(fs.readFileSync(filePath));
return hash.digest('hex'); return hash.digest('hex');
} }
/** /** Validates a backup file's SHA-256 checksum. */
* Validates a backup file's SHA-256 checksum. function validateChecksum(filePath: string, expectedChecksum: unknown): boolean {
* @param {string} filePath - Path to the backup file
* @param {string} expectedChecksum - Expected SHA-256 hex digest
* @returns {boolean} - True if checksum matches
*/
function validateChecksum(filePath, expectedChecksum) {
if (typeof expectedChecksum !== 'string' || !/^[a-f0-9]{64}$/i.test(expectedChecksum)) { if (typeof expectedChecksum !== 'string' || !/^[a-f0-9]{64}$/i.test(expectedChecksum)) {
return false; return false;
} }
@ -80,7 +74,7 @@ function validateChecksum(filePath, expectedChecksum) {
return actualChecksum.toLowerCase() === expectedChecksum.toLowerCase(); return actualChecksum.toLowerCase() === expectedChecksum.toLowerCase();
} }
function cleanupSqliteSidecars(filePath) { function cleanupSqliteSidecars(filePath: string): void {
for (const suffix of ['-wal', '-shm']) { for (const suffix of ['-wal', '-shm']) {
try { try {
const sidecar = `${filePath}${suffix}`; const sidecar = `${filePath}${suffix}`;
@ -89,7 +83,7 @@ function cleanupSqliteSidecars(filePath) {
} }
} }
function metadataForFile(filePath) { function metadataForFile(filePath: string): any {
const id = path.basename(filePath); const id = path.basename(filePath);
assertValidBackupId(id); assertValidBackupId(id);
const stat = fs.statSync(filePath); const stat = fs.statSync(filePath);
@ -111,33 +105,33 @@ function metadataForFile(filePath) {
}; };
} }
function listBackups() { function listBackups(): any[] {
ensureBackupDir(); ensureBackupDir();
return fs.readdirSync(BACKUP_DIR) return fs.readdirSync(BACKUP_DIR)
.filter(name => BACKUP_ID_RE.test(name)) .filter((name: string) => BACKUP_ID_RE.test(name))
.map(name => { .map((name: string) => {
const filePath = backupPathForId(name); const filePath = backupPathForId(name);
if (!fs.statSync(filePath).isFile()) return null; if (!fs.statSync(filePath).isFile()) return null;
return metadataForFile(filePath); return metadataForFile(filePath);
}) })
.filter(Boolean) .filter(Boolean)
.sort((a, b) => b.modified_at.localeCompare(a.modified_at)); .sort((a: any, b: any) => b.modified_at.localeCompare(a.modified_at));
} }
function validateSqliteDatabase(filePath) { function validateSqliteDatabase(filePath: string): void {
let db; let db: any;
try { try {
db = new Database(filePath, { readonly: true, fileMustExist: true }); db = new Database(filePath, { readonly: true, fileMustExist: true });
const result = db.pragma('integrity_check', { simple: true }); const result = db.pragma('integrity_check', { simple: true });
if (result !== 'ok') { if (result !== 'ok') {
const err = new Error('Backup failed SQLite integrity check'); const err = new Error('Backup failed SQLite integrity check') as any;
err.status = 400; err.status = 400;
throw err; throw err;
} }
db.prepare('SELECT name FROM sqlite_master LIMIT 1').get(); db.prepare('SELECT name FROM sqlite_master LIMIT 1').get();
} catch (err) { } catch (err: any) {
if (err.status) throw err; if (err.status) throw err;
const safeErr = new Error('Backup is not a valid SQLite database'); const safeErr = new Error('Backup is not a valid SQLite database') as any;
safeErr.status = 400; safeErr.status = 400;
throw safeErr; throw safeErr;
} finally { } finally {
@ -146,7 +140,7 @@ function validateSqliteDatabase(filePath) {
} }
} }
async function createBackup(prefix = 'bill-tracker-backup') { async function createBackup(prefix = 'bill-tracker-backup'): Promise<any> {
ensureBackupDir(); ensureBackupDir();
const db = getDb(); const db = getDb();
db.pragma('wal_checkpoint(TRUNCATE)'); db.pragma('wal_checkpoint(TRUNCATE)');
@ -156,7 +150,7 @@ async function createBackup(prefix = 'bill-tracker-backup') {
const tempPath = `${finalPath}.partial`; const tempPath = `${finalPath}.partial`;
if (fs.existsSync(finalPath) || fs.existsSync(tempPath)) { if (fs.existsSync(finalPath) || fs.existsSync(tempPath)) {
const err = new Error('Backup file already exists'); const err = new Error('Backup file already exists') as any;
err.status = 409; err.status = 409;
throw err; throw err;
} }
@ -174,11 +168,11 @@ async function createBackup(prefix = 'bill-tracker-backup') {
} }
} }
async function importBackupBuffer(buffer, options = {}) { async function importBackupBuffer(buffer: any, options: any = {}): Promise<any> {
ensureBackupDir(); ensureBackupDir();
if (!Buffer.isBuffer(buffer) || buffer.length === 0) { if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
const err = new Error('Backup upload is required'); const err = new Error('Backup upload is required') as any;
err.status = 400; err.status = 400;
throw err; throw err;
} }
@ -188,7 +182,7 @@ async function importBackupBuffer(buffer, options = {}) {
const tempPath = `${finalPath}.upload`; const tempPath = `${finalPath}.upload`;
if (fs.existsSync(finalPath) || fs.existsSync(tempPath)) { if (fs.existsSync(finalPath) || fs.existsSync(tempPath)) {
const err = new Error('Backup file already exists'); const err = new Error('Backup file already exists') as any;
err.status = 409; err.status = 409;
throw err; throw err;
} }
@ -202,7 +196,7 @@ async function importBackupBuffer(buffer, options = {}) {
if (!validateChecksum(tempPath, providedChecksum)) { if (!validateChecksum(tempPath, providedChecksum)) {
fs.unlinkSync(tempPath); fs.unlinkSync(tempPath);
cleanupSqliteSidecars(tempPath); cleanupSqliteSidecars(tempPath);
const err = new Error('Backup integrity verification failed: checksum mismatch'); const err = new Error('Backup integrity verification failed: checksum mismatch') as any;
err.status = 400; err.status = 400;
throw err; throw err;
} }
@ -219,10 +213,10 @@ async function importBackupBuffer(buffer, options = {}) {
} }
} }
function getBackupFile(id) { function getBackupFile(id: string): { path: string; metadata: any } {
const filePath = backupPathForId(id); const filePath = backupPathForId(id);
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
const err = new Error('Backup not found'); const err = new Error('Backup not found') as any;
err.status = 404; err.status = 404;
throw err; throw err;
} }
@ -232,25 +226,25 @@ function getBackupFile(id) {
}; };
} }
function deleteBackup(id) { function deleteBackup(id: string) {
const backup = getBackupFile(id); const backup = getBackupFile(id);
fs.unlinkSync(backup.path); fs.unlinkSync(backup.path);
cleanupSqliteSidecars(backup.path); cleanupSqliteSidecars(backup.path);
return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() }; return { deleted: true, id: backup.metadata.id, deleted_at: new Date().toISOString() };
} }
function applyRetention(retentionCount, options = {}) { function applyRetention(retentionCount: any, options: any = {}) {
const keep = parseInt(retentionCount, 10); const keep = parseInt(retentionCount, 10);
if (!Number.isInteger(keep) || keep < 1) return { deleted: [] }; if (!Number.isInteger(keep) || keep < 1) return { deleted: [] };
const type = options.type || null; const type = options.type || null;
const backups = type const backups = type
? listBackups().filter(backup => backup.type === type) ? listBackups().filter((backup: any) => backup.type === type)
: listBackups(); : listBackups();
// listBackups() is already sorted newest-first; delete everything beyond `keep`. // listBackups() is already sorted newest-first; delete everything beyond `keep`.
const toDelete = backups.slice(keep); const toDelete = backups.slice(keep);
const deleted = []; const deleted: any[] = [];
for (const backup of toDelete) { for (const backup of toDelete) {
try { try {
@ -263,11 +257,11 @@ function applyRetention(retentionCount, options = {}) {
return { deleted }; return { deleted };
} }
function applyScheduledRetention(retentionCount) { function applyScheduledRetention(retentionCount: any) {
return applyRetention(retentionCount, { type: 'scheduled' }); return applyRetention(retentionCount, { type: 'scheduled' });
} }
async function restoreBackup(id) { async function restoreBackup(id: string): Promise<any> {
const source = getBackupFile(id); const source = getBackupFile(id);
validateSqliteDatabase(source.path); validateSqliteDatabase(source.path);

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
import type { Db } from '../types/db';
const { assertEncryptionReady, encryptSecret, decryptSecret } = require('./encryptionService.cts'); const { assertEncryptionReady, encryptSecret, decryptSecret } = require('./encryptionService.cts');
const { const {
claimSetupToken, claimSetupToken,
@ -16,16 +18,18 @@ const { applyMerchantStoreMatches } = require('./merchantStoreMatchService.cts')
const { autoMatchForUser } = require('./matchSuggestionService.cts'); const { autoMatchForUser } = require('./matchSuggestionService.cts');
const { getUserSettings } = require('./userSettings.cts'); const { getUserSettings } = require('./userSettings.cts');
function sinceEpochDays(days) { type Row = Record<string, any>;
function sinceEpochDays(days: number): number {
return Math.floor((Date.now() - days * 86400 * 1000) / 1000); return Math.floor((Date.now() - days * 86400 * 1000) / 1000);
} }
function safeErrorMessage(err) { function safeErrorMessage(err: any): string {
return sanitizeErrorMessage(err?.message || String(err || 'Sync failed')); return sanitizeErrorMessage(err?.message || String(err || 'Sync failed'));
} }
// Upsert a single financial account, return the local row. // Upsert a single financial account, return the local row.
function upsertAccount(db, accountRow) { function upsertAccount(db: Db, accountRow: Row): Row {
const existing = db.prepare(` const existing = db.prepare(`
SELECT id, monitored FROM financial_accounts SELECT id, monitored FROM financial_accounts
WHERE data_source_id = ? AND provider_account_id = ? AND user_id = ? WHERE data_source_id = ? AND provider_account_id = ? AND user_id = ?
@ -58,16 +62,9 @@ function upsertAccount(db, accountRow) {
return { id: result.lastInsertRowid, monitored: 1 }; return { id: result.lastInsertRowid, monitored: 1 };
} }
// Insert a transaction, or update one we previously stored as PENDING. A pending charge // Insert a transaction, or update one we previously stored as PENDING.
// can change amount before it settles, and eventually flips pending → posted (gaining a
// real posted_date). A transaction we already recorded as posted is final and left alone;
// a row the user has matched or ignored is never touched.
// Returns: 'inserted' | 'posted' (pending→settled) | 'updated' (pending refreshed) | 'skipped'. // Returns: 'inserted' | 'posted' (pending→settled) | 'updated' (pending refreshed) | 'skipped'.
function upsertTransaction(db, txRow) { function upsertTransaction(db: Db, txRow: Row): string {
// Key on (user_id, provider_transaction_id) to match the UNIQUE dedup index —
// provider_transaction_id is deliberately stable across disconnect/reconnect, so
// this also lets the pending→posted / amount-refresh paths survive a reconnect
// (a data_source_id-keyed lookup would miss the prior row and skip the update).
const existing = db.prepare(` const existing = db.prepare(`
SELECT id, pending, match_status FROM transactions SELECT id, pending, match_status FROM transactions
WHERE user_id = ? AND provider_transaction_id = ? WHERE user_id = ? AND provider_transaction_id = ?
@ -87,7 +84,7 @@ function upsertTransaction(db, txRow) {
txRow.description, txRow.payee, txRow.memo, txRow.match_status, txRow.ignored, txRow.pending, txRow.raw_data, txRow.description, txRow.payee, txRow.memo, txRow.match_status, txRow.ignored, txRow.pending, txRow.raw_data,
); );
return 'inserted'; return 'inserted';
} catch (err) { } catch (err: any) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE' || (err.message || '').includes('UNIQUE')) { if (err.code === 'SQLITE_CONSTRAINT_UNIQUE' || (err.message || '').includes('UNIQUE')) {
return 'skipped'; return 'skipped';
} }
@ -114,12 +111,9 @@ function upsertTransaction(db, txRow) {
return 'skipped'; return 'skipped';
} }
async function runSync(db, userId, dataSource, { days, debug = false } = {}) { async function runSync(db: Db, userId: number, dataSource: Row, { days, debug = false }: { days?: number; debug?: boolean } = {}): Promise<any> {
const accessUrl = decryptSecret(dataSource.encrypted_secret); const accessUrl = decryptSecret(dataSource.encrypted_secret);
const isFirstSync = !dataSource.last_sync_at; const isFirstSync = !dataSource.last_sync_at;
// Explicit `days` param (e.g. backfill) takes precedence.
// Initial seed always uses the full SYNC_DAYS_EFFECTIVE window regardless of admin config.
// Routine syncs use the admin-configured sync_days (default 30); falls back to SYNC_DAYS_DEFAULT.
const config = getBankSyncConfig(); const config = getBankSyncConfig();
const syncDays = days ?? (isFirstSync ? SYNC_DAYS_EFFECTIVE : (config.sync_days || SYNC_DAYS_DEFAULT)); const syncDays = days ?? (isFirstSync ? SYNC_DAYS_EFFECTIVE : (config.sync_days || SYNC_DAYS_DEFAULT));
const since = sinceEpochDays(syncDays); const since = sinceEpochDays(syncDays);
@ -141,15 +135,12 @@ async function runSync(db, userId, dataSource, { days, debug = false } = {}) {
let transactionsPosted = 0; // pending → settled this cycle let transactionsPosted = 0; // pending → settled this cycle
let pendingCleared = 0; // stale pending rows pruned (re-posted under new id / dropped) let pendingCleared = 0; // stale pending rows pruned (re-posted under new id / dropped)
// Remove pending rows we hold for an account that are no longer in the feed — a pending
// charge that re-posted under a new id, or was dropped by the bank before settling.
const pruneOrphanPending = db.prepare(` const pruneOrphanPending = db.prepare(`
DELETE FROM transactions DELETE FROM transactions
WHERE account_id = ? AND user_id = ? AND pending = 1 AND match_status = 'unmatched' WHERE account_id = ? AND user_id = ? AND pending = 1 AND match_status = 'unmatched'
AND provider_transaction_id NOT IN (SELECT value FROM json_each(?)) AND provider_transaction_id NOT IN (SELECT value FROM json_each(?))
`); `);
// Store any errlist warnings alongside a successful sync so users can see them
const partialError = raw._errlistSummary const partialError = raw._errlistSummary
? sanitizeErrorMessage(`Partial sync — some connections failed: ${raw._errlistSummary}`) ? sanitizeErrorMessage(`Partial sync — some connections failed: ${raw._errlistSummary}`)
: null; : null;
@ -160,10 +151,6 @@ async function runSync(db, userId, dataSource, { days, debug = false } = {}) {
WHERE id = ? AND user_id = ? WHERE id = ? AND user_id = ?
`); `);
// Batch every account + transaction write and the source-status update into ONE
// commit (better-sqlite3 db.transaction): far fewer fsyncs on large syncs, and
// atomic — a mid-sync failure can't leave a half-written account. The whole
// block is synchronous (the async fetch already happened above).
const ingest = db.transaction(() => { const ingest = db.transaction(() => {
for (const rawAccount of accounts) { for (const rawAccount of accounts) {
const accountRow = normalizeAccount(rawAccount, dataSource.id, userId); const accountRow = normalizeAccount(rawAccount, dataSource.id, userId);
@ -175,7 +162,7 @@ async function runSync(db, userId, dataSource, { days, debug = false } = {}) {
if (localAccount.monitored === 0) continue; if (localAccount.monitored === 0) continue;
const seenTxIds = []; const seenTxIds: any[] = [];
for (const rawTx of txList) { for (const rawTx of txList) {
const txRow = normalizeTransaction( const txRow = normalizeTransaction(
rawTx, localAccount.id, dataSource.id, userId, rawAccount.id, rawAccount.currency, rawTx, localAccount.id, dataSource.id, userId, rawAccount.id, rawAccount.currency,
@ -226,7 +213,7 @@ async function runSync(db, userId, dataSource, { days, debug = false } = {}) {
// ─── Public API ─────────────────────────────────────────────────────────────── // ─── Public API ───────────────────────────────────────────────────────────────
async function connectSimplefin(db, userId, setupToken) { async function connectSimplefin(db: Db, userId: number, setupToken: any): Promise<any> {
assertEncryptionReady(); assertEncryptionReady();
const accessUrl = await claimSetupToken(setupToken); const accessUrl = await claimSetupToken(setupToken);
@ -240,7 +227,7 @@ async function connectSimplefin(db, userId, setupToken) {
const dataSourceId = result.lastInsertRowid; const dataSourceId = result.lastInsertRowid;
const dataSource = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId); const dataSource = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId);
let syncResult = { accountsUpserted: 0, transactionsNew: 0, transactionsSkip: 0 }; let syncResult: any = { accountsUpserted: 0, transactionsNew: 0, transactionsSkip: 0 };
try { try {
syncResult = await runSync(db, userId, dataSource); syncResult = await runSync(db, userId, dataSource);
} catch (err) { } catch (err) {
@ -255,7 +242,7 @@ async function connectSimplefin(db, userId, setupToken) {
return { dataSource: decorateDataSource(fresh), ...syncResult }; return { dataSource: decorateDataSource(fresh), ...syncResult };
} }
async function syncDataSource(db, userId, dataSourceId, { debug } = {}) { async function syncDataSource(db: Db, userId: number, dataSourceId: any, { debug }: { debug?: boolean } = {}): Promise<any> {
assertEncryptionReady(); assertEncryptionReady();
const dataSource = db.prepare(` const dataSource = db.prepare(`
@ -285,7 +272,7 @@ async function syncDataSource(db, userId, dataSourceId, { debug } = {}) {
return { dataSource: decorateDataSource(fresh), ...syncResult }; return { dataSource: decorateDataSource(fresh), ...syncResult };
} }
async function backfillDataSource(db, userId, dataSourceId) { async function backfillDataSource(db: Db, userId: number, dataSourceId: any): Promise<any> {
assertEncryptionReady(); assertEncryptionReady();
const dataSource = db.prepare(` const dataSource = db.prepare(`
@ -312,7 +299,7 @@ async function backfillDataSource(db, userId, dataSourceId) {
return { dataSource: decorateDataSource(fresh), ...syncResult }; return { dataSource: decorateDataSource(fresh), ...syncResult };
} }
function disconnectDataSource(db, userId, dataSourceId) { function disconnectDataSource(db: Db, userId: number, dataSourceId: any): void {
const row = db.prepare(` const row = db.prepare(`
SELECT id FROM data_sources WHERE id = ? AND user_id = ? AND provider = 'simplefin' SELECT id FROM data_sources WHERE id = ? AND user_id = ? AND provider = 'simplefin'
`).get(dataSourceId, userId); `).get(dataSourceId, userId);

View File

@ -4,7 +4,7 @@ import type { Db } from '../types/db';
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { getBankSyncConfig } = require('./bankSyncConfigService.cts'); const { getBankSyncConfig } = require('./bankSyncConfigService.cts');
const { syncDataSource } = require('./bankSyncService'); const { syncDataSource } = require('./bankSyncService.cts');
// Skip a source if it was synced less than this long ago (catches recent manual syncs) // Skip a source if it was synced less than this long ago (catches recent manual syncs)
const MIN_SYNC_AGE_MS = 60 * 60 * 1000; // 1 hour const MIN_SYNC_AGE_MS = 60 * 60 * 1000; // 1 hour

View File

@ -1,21 +1,23 @@
'use strict'; 'use strict';
import type { Db } from '../types/db';
const os = require('os'); const os = require('os');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { getDb, getSetting, setSetting } = require('../db/database'); const { getDb, getSetting, setSetting } = require('../db/database');
const { BACKUP_DIR } = require('./backupService'); const { BACKUP_DIR } = require('./backupService.cts');
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
function parseBool(key, defaultTrue = true) { function parseBool(key: string, defaultTrue = true): boolean {
const v = getSetting(key); const v = getSetting(key);
if (v === null) return defaultTrue; if (v === null) return defaultTrue;
return v !== 'false'; return v !== 'false';
} }
function parseIntSetting(key, fallback) { function parseIntSetting(key: string, fallback: number): number {
const v = parseInt(getSetting(key) || '', 10); const v = parseInt(getSetting(key) || '', 10);
return isNaN(v) ? fallback : v; return isNaN(v) ? fallback : v;
} }
@ -26,7 +28,7 @@ function parseIntSetting(key, fallback) {
* Delete rows from import_sessions where expires_at has passed. * Delete rows from import_sessions where expires_at has passed.
* These are created by the spreadsheet import preview endpoint (24h TTL). * These are created by the spreadsheet import preview endpoint (24h TTL).
*/ */
function pruneExpiredImportSessions() { function pruneExpiredImportSessions(): number {
const result = getDb() const result = getDb()
.prepare("DELETE FROM import_sessions WHERE expires_at <= datetime('now')") .prepare("DELETE FROM import_sessions WHERE expires_at <= datetime('now')")
.run(); .run();
@ -35,16 +37,8 @@ function pruneExpiredImportSessions() {
/** /**
* Remove stale export temp files from the OS temp directory. * Remove stale export temp files from the OS temp directory.
*
* SQLite exports (user-db): bill-tracker-user-{id}-{ts}.sqlite
* Written by the /export/user-db route, deleted in the download callback.
* Server crash can leave orphans swept with maxAgeHours.
*
* Excel exports (user-excel): bill-tracker-user-{id}-{ts}.xlsx
* Currently streamed in-memory (no disk file), but guard exists so that
* if the code path ever changes, xlsx files are always removed within 24 h.
*/ */
function pruneStaleExportFiles(maxAgeHours) { function pruneStaleExportFiles(maxAgeHours: number): { removed: number; errors: number } {
const tmpDir = os.tmpdir(); const tmpDir = os.tmpdir();
const sqliteCutoff = maxAgeHours * 60 * 60 * 1000; const sqliteCutoff = maxAgeHours * 60 * 60 * 1000;
const xlsxCutoff = 24 * 60 * 60 * 1000; // xlsx files must not outlive 24 h const xlsxCutoff = 24 * 60 * 60 * 1000; // xlsx files must not outlive 24 h
@ -52,10 +46,10 @@ function pruneStaleExportFiles(maxAgeHours) {
let removed = 0; let removed = 0;
let errors = 0; let errors = 0;
let entries; let entries: string[];
try { try {
entries = fs.readdirSync(tmpDir); entries = fs.readdirSync(tmpDir);
} catch (err) { } catch (err: any) {
console.warn('[cleanup] Cannot read tmpdir:', err.message); console.warn('[cleanup] Cannot read tmpdir:', err.message);
return { removed, errors: 1 }; return { removed, errors: 1 };
} }
@ -84,10 +78,8 @@ function pruneStaleExportFiles(maxAgeHours) {
/** /**
* Remove orphaned .partial and .upload backup files from the backup directory. * Remove orphaned .partial and .upload backup files from the backup directory.
* These are left behind only if the server crashes mid-backup or mid-import.
* Uses a 2-hour cutoff so an in-progress operation is never interrupted.
*/ */
function pruneOrphanedBackupPartials(backupDir) { function pruneOrphanedBackupPartials(backupDir: string): { removed: number; errors: number } {
const cutoff = 2 * 60 * 60 * 1000; const cutoff = 2 * 60 * 60 * 1000;
const now = Date.now(); const now = Date.now();
let removed = 0; let removed = 0;
@ -95,10 +87,10 @@ function pruneOrphanedBackupPartials(backupDir) {
if (!backupDir || !fs.existsSync(backupDir)) return { removed, errors }; if (!backupDir || !fs.existsSync(backupDir)) return { removed, errors };
let entries; let entries: string[];
try { try {
entries = fs.readdirSync(backupDir); entries = fs.readdirSync(backupDir);
} catch (err) { } catch (err: any) {
console.warn('[cleanup] Cannot read backup dir:', err.message); console.warn('[cleanup] Cannot read backup dir:', err.message);
return { removed, errors: 1 }; return { removed, errors: 1 };
} }
@ -121,10 +113,9 @@ function pruneOrphanedBackupPartials(backupDir) {
} }
/** /**
* Trim import_history rows older than maxAgeDays. * Trim import_history rows older than maxAgeDays. Disabled by default.
* Rows are per-user audit records. This task is disabled by default.
*/ */
function pruneImportHistory(maxAgeDays) { function pruneImportHistory(maxAgeDays: number): number {
if (!maxAgeDays || maxAgeDays <= 0) return 0; if (!maxAgeDays || maxAgeDays <= 0) return 0;
const result = getDb() const result = getDb()
.prepare("DELETE FROM import_history WHERE imported_at < datetime('now', ?)") .prepare("DELETE FROM import_history WHERE imported_at < datetime('now', ?)")
@ -134,16 +125,10 @@ function pruneImportHistory(maxAgeDays) {
/** /**
* Permanently purge soft-deleted bills and categories after a 30-day recovery * Permanently purge soft-deleted bills and categories after a 30-day recovery
* window. Bill deletion cascades to bill-owned records via foreign keys. * window. Also releases limbo matches back to 'unmatched' (see QA-B5-04).
*
* transactions.matched_bill_id is ON DELETE SET NULL, so purging a bill nulls the
* pointer on any matched transaction but would leave match_status='matched' a
* limbo row excluded from spending (match_status != 'matched') yet attributed to no
* bill. Release those matches back to 'unmatched' in the same transaction (and
* self-heal any pre-existing orphans) so purged bills don't silently drop spend.
*/ */
function pruneSoftDeletedFinancialRecords(maxAgeDays = 30) { function pruneSoftDeletedFinancialRecords(maxAgeDays = 30): { bills: number; categories: number; releasedMatches: number } {
const db = getDb(); const db: Db = getDb();
const cutoff = `-${maxAgeDays} days`; const cutoff = `-${maxAgeDays} days`;
const purge = db.transaction(() => { const purge = db.transaction(() => {
const releasedMatches = db.prepare(` const releasedMatches = db.prepare(`
@ -175,17 +160,17 @@ function readSettings() {
}; };
} }
function validateAndApplySettings(input = {}) { function validateAndApplySettings(input: any = {}) {
const toSave = {}; const toSave: Record<string, string> = {};
const bool = (key, settingKey) => { const bool = (key: string, settingKey: string) => {
if (input[key] !== undefined) toSave[settingKey] = input[key] ? 'true' : 'false'; if (input[key] !== undefined) toSave[settingKey] = input[key] ? 'true' : 'false';
}; };
const rangedInt = (key, settingKey, min, max, label) => { const rangedInt = (key: string, settingKey: string, min: number, max: number, label: string) => {
if (input[key] === undefined) return; if (input[key] === undefined) return;
const v = parseInt(input[key], 10); const v = parseInt(input[key], 10);
if (isNaN(v) || v < min || v > max) { if (isNaN(v) || v < min || v > max) {
const err = new Error(`${label} must be between ${min} and ${max}`); const err = new Error(`${label} must be between ${min} and ${max}`) as any;
err.status = 400; err.status = 400;
throw err; throw err;
} }
@ -208,7 +193,7 @@ function validateAndApplySettings(input = {}) {
async function runAllCleanup() { async function runAllCleanup() {
const settings = readSettings(); const settings = readSettings();
const tasks = {}; const tasks: Record<string, any> = {};
if (settings.import_sessions_enabled) { if (settings.import_sessions_enabled) {
const pruned = pruneExpiredImportSessions(); const pruned = pruneExpiredImportSessions();
@ -250,7 +235,7 @@ async function runAllCleanup() {
function getCleanupStatus() { function getCleanupStatus() {
const raw = getSetting('cleanup_last_result'); const raw = getSetting('cleanup_last_result');
let last_result = null; let last_result: any = null;
try { if (raw) last_result = JSON.parse(raw); } catch {} try { if (raw) last_result = JSON.parse(raw); } catch {}
return { return {

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
import type { Db } from '../types/db';
const crypto = require('crypto'); const crypto = require('crypto');
const { const {
generateRegistrationOptions, generateRegistrationOptions,
@ -11,11 +13,11 @@ const {
const APP_NAME = 'Bill Tracker'; const APP_NAME = 'Bill Tracker';
const CHALLENGE_TTL_MS = 15 * 60 * 1000; const CHALLENGE_TTL_MS = 15 * 60 * 1000;
function getRpId() { function getRpId(): string {
return process.env.WEBAUTHN_RP_ID || 'localhost'; return process.env.WEBAUTHN_RP_ID || 'localhost';
} }
function getOrigin() { function getOrigin(): string {
const o = process.env.WEBAUTHN_ORIGIN; const o = process.env.WEBAUTHN_ORIGIN;
if (o) return o; if (o) return o;
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
@ -24,7 +26,7 @@ function getOrigin() {
// ── Registration ────────────────────────────────────────────────────────────── // ── Registration ──────────────────────────────────────────────────────────────
async function createRegistrationChallenge(db, userId, username) { async function createRegistrationChallenge(db: Db, userId: number, username: string) {
let { webauthn_user_id } = db.prepare('SELECT webauthn_user_id FROM users WHERE id = ?').get(userId) || {}; let { webauthn_user_id } = db.prepare('SELECT webauthn_user_id FROM users WHERE id = ?').get(userId) || {};
if (!webauthn_user_id) { if (!webauthn_user_id) {
@ -43,7 +45,7 @@ async function createRegistrationChallenge(db, userId, username) {
userName: username, userName: username,
userDisplayName: username, userDisplayName: username,
attestationType: 'none', attestationType: 'none',
excludeCredentials: existing.map(c => ({ id: c.credential_id })), excludeCredentials: existing.map((c: any) => ({ id: c.credential_id })),
authenticatorSelection: { authenticatorSelection: {
residentKey: 'preferred', residentKey: 'preferred',
userVerification: 'preferred', userVerification: 'preferred',
@ -61,7 +63,7 @@ async function createRegistrationChallenge(db, userId, username) {
return { options, challengeId }; return { options, challengeId };
} }
async function verifyRegistration(db, userId, challengeId, response, credentialName) { async function verifyRegistration(db: Db, userId: number, challengeId: string, response: any, credentialName: any) {
const row = db.prepare( const row = db.prepare(
"SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'registration' AND expires_at > datetime('now')" "SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'registration' AND expires_at > datetime('now')"
).get(challengeId, userId); ).get(challengeId, userId);
@ -98,7 +100,7 @@ async function verifyRegistration(db, userId, challengeId, response, credentialN
db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeId); db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeId);
return { verified: true, credentialId: credential.id }; return { verified: true, credentialId: credential.id };
} catch (err) { } catch (err: any) {
console.error('[webauthn] registration error:', err.message); console.error('[webauthn] registration error:', err.message);
return { verified: false, error: err.message }; return { verified: false, error: err.message };
} }
@ -106,13 +108,13 @@ async function verifyRegistration(db, userId, challengeId, response, credentialN
// ── Authentication ──────────────────────────────────────────────────────────── // ── Authentication ────────────────────────────────────────────────────────────
async function createAuthenticationChallenge(db, userId) { async function createAuthenticationChallenge(db: Db, userId: number) {
const credentials = db.prepare('SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?').all(userId); const credentials = db.prepare('SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?').all(userId);
if (!credentials.length) throw new Error('No registered WebAuthn credentials'); if (!credentials.length) throw new Error('No registered WebAuthn credentials');
const options = await generateAuthenticationOptions({ const options = await generateAuthenticationOptions({
rpID: getRpId(), rpID: getRpId(),
allowCredentials: credentials.map(c => ({ allowCredentials: credentials.map((c: any) => ({
id: c.credential_id, id: c.credential_id,
transports: c.transports ? JSON.parse(c.transports) : undefined, transports: c.transports ? JSON.parse(c.transports) : undefined,
})), })),
@ -129,7 +131,7 @@ async function createAuthenticationChallenge(db, userId) {
return { options, challengeId }; return { options, challengeId };
} }
async function verifyAuthentication(db, userId, challengeId, response) { async function verifyAuthentication(db: Db, userId: number, challengeId: string, response: any) {
const row = db.prepare( const row = db.prepare(
"SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')" "SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')"
).get(challengeId, userId); ).get(challengeId, userId);
@ -161,7 +163,7 @@ async function verifyAuthentication(db, userId, challengeId, response) {
db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeId); db.prepare('DELETE FROM webauthn_challenges WHERE id = ?').run(challengeId);
return { verified: true }; return { verified: true };
} catch (err) { } catch (err: any) {
console.error('[webauthn] authentication error:', err.message); console.error('[webauthn] authentication error:', err.message);
return { verified: false, error: err.message }; return { verified: false, error: err.message };
} }
@ -170,7 +172,7 @@ async function verifyAuthentication(db, userId, challengeId, response) {
// ── Login challenge (mirrors totpService.createChallenge / consumeChallenge) ── // ── Login challenge (mirrors totpService.createChallenge / consumeChallenge) ──
// Issued after password passes; consumed when the WebAuthn assertion is verified. // Issued after password passes; consumed when the WebAuthn assertion is verified.
function createLoginChallenge(db, userId, webauthnChallengeId) { function createLoginChallenge(db: Db, userId: number, webauthnChallengeId: any): string {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' '); const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS).toISOString().slice(0, 19).replace('T', ' ');
db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'login'").run(userId); db.prepare("DELETE FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'login'").run(userId);
@ -180,7 +182,7 @@ function createLoginChallenge(db, userId, webauthnChallengeId) {
return id; return id;
} }
function consumeLoginChallenge(db, loginChallengeId) { function consumeLoginChallenge(db: Db, loginChallengeId: any): { userId: number; authChallengeId: string } | null {
const row = db.prepare( const row = db.prepare(
"SELECT user_id, challenge FROM webauthn_challenges WHERE id = ? AND challenge_type = 'login' AND expires_at > datetime('now')" "SELECT user_id, challenge FROM webauthn_challenges WHERE id = ? AND challenge_type = 'login' AND expires_at > datetime('now')"
).get(loginChallengeId); ).get(loginChallengeId);
@ -191,19 +193,19 @@ function consumeLoginChallenge(db, loginChallengeId) {
// ── Credential management ───────────────────────────────────────────────────── // ── Credential management ─────────────────────────────────────────────────────
function getCredentials(db, userId) { function getCredentials(db: Db, userId: number): any[] {
return db.prepare( return db.prepare(
'SELECT id, credential_id, credential_name, aaguid, backup_eligible, backup_state, created_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC' 'SELECT id, credential_id, credential_name, aaguid, backup_eligible, backup_state, created_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC'
).all(userId); ).all(userId);
} }
function deleteCredential(db, credentialId, userId) { function deleteCredential(db: Db, credentialId: any, userId: number) {
return db.prepare('DELETE FROM webauthn_credentials WHERE credential_id = ? AND user_id = ?').run(credentialId, userId); return db.prepare('DELETE FROM webauthn_credentials WHERE credential_id = ? AND user_id = ?').run(credentialId, userId);
} }
// ── Cleanup ─────────────────────────────────────────────────────────────────── // ── Cleanup ───────────────────────────────────────────────────────────────────
function pruneExpiredChallenges(db) { function pruneExpiredChallenges(db: Db): void {
db.prepare("DELETE FROM webauthn_challenges WHERE expires_at <= datetime('now')").run(); db.prepare("DELETE FROM webauthn_challenges WHERE expires_at <= datetime('now')").run();
} }

View File

@ -17,7 +17,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-authsvc-${process.pid}.sqlit
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const auth = require('../services/authService'); const auth = require('../services/authService.cts');
const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex'); const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
let db, userId, otherUserId; let db, userId, otherUserId;

View File

@ -25,7 +25,7 @@ const {
applyScheduledRetention, applyScheduledRetention,
importBackupBuffer, importBackupBuffer,
checksumFile, checksumFile,
} = require('../services/backupService'); } = require('../services/backupService.cts');
const { const {
validateScheduleSettings, validateScheduleSettings,
computeNextRun, computeNextRun,
@ -35,7 +35,7 @@ const {
pruneOrphanedBackupPartials, pruneOrphanedBackupPartials,
pruneStaleExportFiles, pruneStaleExportFiles,
pruneSoftDeletedFinancialRecords, pruneSoftDeletedFinancialRecords,
} = require('../services/cleanupService'); } = require('../services/cleanupService.cts');
// ── Teardown ───────────────────────────────────────────────────────────────── // ── Teardown ─────────────────────────────────────────────────────────────────
test.after(() => { test.after(() => {

View File

@ -9,7 +9,7 @@ process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { encryptSecret } = require('../services/encryptionService.cts'); const { encryptSecret } = require('../services/encryptionService.cts');
const { syncDataSource } = require('../services/bankSyncService'); const { syncDataSource } = require('../services/bankSyncService.cts');
function createUser(db, suffix) { function createUser(db, suffix) {
return db.prepare(` return db.prepare(`

View File

@ -8,7 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-profile-route-test-${process
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { publicUser } = require('../services/authService'); const { publicUser } = require('../services/authService.cts');
function createUser(db, suffix) { function createUser(db, suffix) {
return db.prepare(` return db.prepare(`

View File

@ -3,10 +3,10 @@
const cron = require('node-cron'); const cron = require('node-cron');
const { getDb, getSetting } = require('../db/database'); const { getDb, getSetting } = require('../db/database');
const { buildTrackerRow, getCycleRange } = require('../services/statusService.cts'); const { buildTrackerRow, getCycleRange } = require('../services/statusService.cts');
const { pruneExpiredSessions } = require('../services/authService'); const { pruneExpiredSessions } = require('../services/authService.cts');
const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService'); const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService.cts');
const { runNotifications, runDriftNotifications } = require('../services/notificationService'); const { runNotifications, runDriftNotifications } = require('../services/notificationService');
const { runAllCleanup } = require('../services/cleanupService'); const { runAllCleanup } = require('../services/cleanupService.cts');
const { const {
markWorkerError, markWorkerError,
markWorkerStarted, markWorkerStarted,