refactor(server): migrate 8 leaf services + apiError to TypeScript (.cts)

Batch of low-blast-radius modules: utils/apiError, and services statusRuntime,
loginFingerprint, auditService, transactionMatchState, updateCheckService,
advisoryFilterService, bankSyncConfigService.

- types/http.d.ts: shared permissive Express-ish Req/Res/Next/Router types
  (@types/express isn't installed; handlers are dynamic) — for apiError's
  errorHandler now and the routes/middleware batches next.
- Every require of a migrated module updated to the explicit .cts extension.
- Confirmed + documented the Node rule: a .cts needs at least one `import`
  statement or type-stripping never activates (node --check fails on the first
  type token); import-less modules lead with an `import type`.

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 10:47:16 -05:00
parent 005b0b7b64
commit a6c9b6cb1c
31 changed files with 219 additions and 112 deletions

View File

@ -7,7 +7,7 @@ const fs = require('fs');
let _logAudit = null;
function getLogAudit() {
if (!_logAudit) {
try { _logAudit = require('../services/auditService').logAudit; } catch { _logAudit = () => {}; }
try { _logAudit = require('../services/auditService.cts').logAudit; } catch { _logAudit = () => {}; }
}
return _logAudit;
}

View File

@ -1,5 +1,5 @@
const crypto = require('crypto');
const { logAudit } = require('../services/auditService');
const { logAudit } = require('../services/auditService.cts');
// ─────────────────────────────────────────────────────────────────────────────
// CSRF Middleware

View File

@ -10,7 +10,7 @@
* }
*/
const { ValidationError, formatError } = require('../utils/apiError');
const { ValidationError, formatError } = require('../utils/apiError.cts');
/**
* Extract field name from various validation error patterns

View File

@ -475,7 +475,7 @@ router.get('/dev-log', requireAuth, requireAdmin, (req, res) => {
}
});
const { checkForUpdates } = require('../services/updateCheckService');
const { checkForUpdates } = require('../services/updateCheckService.cts');
// POST /api/about-admin/check-updates — force a fresh update check, bypassing cache
router.post('/check-updates', requireAuth, requireAdmin, async (req, res) => {

View File

@ -1,11 +1,11 @@
const express = require('express');
const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database');
const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService');
const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService.cts');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
const { isEnvKeyActive, keyFingerprint } = require('../services/encryptionService.cts');
const { hashPassword } = require('../services/authService');
const { logAudit } = require('../services/auditService');
const { logAudit } = require('../services/auditService.cts');
const {
createBackup,
deleteBackup,

View File

@ -15,10 +15,10 @@ const { decryptSecret } = require('../services/encryptionService.cts');
const { getCsrfToken } = require('../middleware/csrf');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
const { getPublicOidcInfo } = require('../services/oidcService');
const { ValidationError, formatError } = require('../utils/apiError');
const { ValidationError, formatError } = require('../utils/apiError.cts');
const { standardizeError } = require('../middleware/errorFormatter');
const { passwordLimiter } = require('../middleware/rateLimiter');
const { logAudit } = require('../services/auditService');
const { logAudit } = require('../services/auditService.cts');
// ─────────────────────────────────────────
// PUBLIC AUTH ROUTES

View File

@ -6,7 +6,7 @@ const { standardizeError } = require('../middleware/erro
const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService');
const { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource } = require('../services/bankSyncService');
const { sanitizeErrorMessage } = require('../services/simplefinService');
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts');
const { syncLimiter } = require('../middleware/rateLimiter');
const VALID_TYPES = new Set(['manual', 'file_import', 'provider_sync']);

View File

@ -7,7 +7,7 @@ const {
rejectMatchSuggestion,
} = require('../services/matchSuggestionService');
const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService.cts');
const { markMatched, markUnmatched } = require('../services/transactionMatchState');
const { markMatched, markUnmatched } = require('../services/transactionMatchState.cts');
const { serializePayment } = require('../services/paymentValidation.cts');
const { todayLocal } = require('../utils/dates.mts');

View File

@ -5,7 +5,7 @@ const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
const { getCycleRange, resolveDueDate } = require('../services/statusService.cts');
const { markUnmatched } = require('../services/transactionMatchState');
const { markUnmatched } = require('../services/transactionMatchState.cts');
const {
markProvisionalManualPaymentsOverridden,
reactivatePaymentsOverriddenBy,

View File

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

View File

@ -3,12 +3,12 @@ const router = express.Router();
const fs = require('fs');
const os = require('os');
const { getDb, getSetting } = require('../db/database');
const { getStatusRuntime, recordError } = require('../services/statusRuntime');
const { getStatusRuntime, recordError } = require('../services/statusRuntime.cts');
const { listBackups } = require('../services/backupService');
const { getScheduleStatus } = require('../services/backupScheduler');
const { checkForUpdates } = require('../services/updateCheckService');
const { checkForUpdates } = require('../services/updateCheckService.cts');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts');
const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const { localDateString } = require('../utils/dates.mts');

View File

@ -6,9 +6,9 @@ const {
ensureManualDataSource,
getTransactionForUser,
} = require('../services/transactionService');
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService');
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
const { markUnmatched } = require('../services/transactionMatchState');
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService.cts');
const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts');
const { markUnmatched } = require('../services/transactionMatchState.cts');
const {
ignoreTransaction,
matchTransactionToBill,

View File

@ -78,7 +78,7 @@ router.get('/history', (req, res) => {
});
// GET /api/version/update-status — public, returns cached update check (no force-refresh)
const { checkForUpdates } = require('../services/updateCheckService');
const { checkForUpdates } = require('../services/updateCheckService.cts');
router.get('/update-status', async (req, res) => {
try {

View File

@ -4,9 +4,9 @@ const path = require('path');
const { getDb } = require('./db/database');
const { requireAuth, requireUser, requireAdmin } = require('./middleware/requireAuth');
const { recordError } = require('./services/statusRuntime');
const { recordError } = require('./services/statusRuntime.cts');
const { securityHeaders } = require('./middleware/securityHeaders');
const { logAudit } = require('./services/auditService');
const { logAudit } = require('./services/auditService.cts');
const { errorFormatter } = require('./middleware/errorFormatter');
const { importLimiter, exportLimiter, adminActionLimiter, oidcLimiter, loginLimiter, loginUsernameLimiter, passwordLimiter, backupOperationLimiter } =
require('./middleware/rateLimiter');

View File

@ -1,12 +1,14 @@
'use strict';
import type { Db } from '../types/db';
const { getDb } = require('../db/database');
// Lazy-loaded in-memory cache — loaded once on first use
let _patterns = null;
let _overrideTerms = null;
let _patterns: any[] | null = null;
let _overrideTerms: string[] | null = null;
function normalize(text) {
function normalize(text: unknown): string {
if (!text) return '';
return String(text)
.toLowerCase()
@ -15,15 +17,15 @@ function normalize(text) {
.trim();
}
function loadCache() {
function loadCache(): void {
if (_patterns !== null) return;
const db = getDb();
const db: Db = getDb();
_patterns = db.prepare(
'SELECT pattern, confidence, category, rationale FROM advisory_non_bill_filters'
).all();
_overrideTerms = db.prepare(
'SELECT term FROM advisory_bill_like_overrides'
).all().map(r => r.term);
).all().map((r: any) => r.term);
}
/**
@ -31,7 +33,7 @@ function loadCache() {
* Returns null if Create Bill should be shown normally, or
* { confidence: 'high'|'medium', category, rationale } if it should be suppressed.
*/
function checkTransaction(title) {
function checkTransaction(title: unknown): { confidence: string; category: any; rationale: any } | null {
if (!title) return null;
try {
loadCache();
@ -43,15 +45,15 @@ function checkTransaction(title) {
if (!normalized) return null;
// Bill-like override terms take priority — always show Create Bill
for (const term of _overrideTerms) {
for (const term of _overrideTerms!) {
if (normalized.includes(term)) return null;
}
// Find the highest-confidence matching pattern
let highMatch = null;
let mediumMatch = null;
let highMatch: any = null;
let mediumMatch: any = null;
for (const row of _patterns) {
for (const row of _patterns!) {
if (normalized.includes(row.pattern)) {
if (row.confidence === 'high') {
highMatch = row;
@ -73,7 +75,7 @@ function checkTransaction(title) {
}
/** Clear the in-memory cache (used after re-seeding in tests). */
function clearCache() {
function clearCache(): void {
_patterns = null;
_overrideTerms = null;
}

View File

@ -1,18 +1,22 @@
import type { Db } from '../types/db';
const { getDb } = require('../db/database');
interface AuditParams {
user_id?: number | null;
action: string;
entity_type?: string | null;
entity_id?: number | null;
details?: any;
ip_address?: string | null;
user_agent?: string | null;
}
/**
* Log a security-sensitive action to the audit_log table.
* @param {Object} params
* @param {number|null} params.user_id - User ID (null for anonymous/failed attempts)
* @param {string} params.action - Action type (e.g., 'login.success', 'login.failure', 'password.change', 'role.change', 'session.invalidate')
* @param {string} [params.entity_type] - Entity type (e.g., 'user', 'session', 'bill')
* @param {number} [params.entity_id] - Entity ID
* @param {Object} [params.details] - Additional details (stored as JSON)
* @param {string} [params.ip_address] - Request IP
* @param {string} [params.user_agent] - Request user-agent
*/
function logAudit({ user_id, action, entity_type, entity_id, details, ip_address, user_agent }) {
const db = getDb();
function logAudit({ user_id, action, entity_type, entity_id, details, ip_address, user_agent }: AuditParams): void {
const db: Db = getDb();
try {
db.prepare(
`INSERT INTO audit_log (user_id, action, entity_type, entity_id, details_json, ip_address, user_agent)
@ -26,10 +30,10 @@ function logAudit({ user_id, action, entity_type, entity_id, details, ip_address
ip_address || null,
user_agent || null
);
} catch (err) {
} catch (err: any) {
// Audit logging should never crash the app
console.error('[audit-error] Failed to log audit event:', err.message);
}
}
module.exports = { logAudit };
module.exports = { logAudit };

View File

@ -1,7 +1,7 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { getDb } = require('../db/database');
const { buildDeviceFingerprint } = require('./loginFingerprint');
const { buildDeviceFingerprint } = require('./loginFingerprint.cts');
const { encryptSecret, decryptSecret } = require('./encryptionService.cts');
// Store SHA-256(token) in the DB; the raw token stays only in the cookie.

View File

@ -1,5 +1,8 @@
'use strict';
// import required so Node type-strips this .cts (it has no other import).
import type { Db } from '../types/db';
const { getSetting, setSetting } = require('../db/database');
const SYNC_DAYS_MAX = 45; // SimpleFIN Bridge hard limit — requests beyond this return an error
@ -7,11 +10,20 @@ const SYNC_DAYS_EFFECTIVE = 44; // 1-day buffer so request latency never tips
const SYNC_DAYS_DEFAULT = 30; // routine sync window (initial seed always uses SYNC_DAYS_EFFECTIVE)
const SYNC_INTERVAL_DEFAULT = 4; // hours
function getBankSyncConfig() {
interface BankSyncConfig {
enabled: boolean;
sync_days: number;
seed_days: number;
sync_days_max: number;
sync_interval_hours: number;
debug_logging: boolean;
}
function getBankSyncConfig(): BankSyncConfig {
const dbValue = getSetting('bank_sync_enabled');
const envValue = process.env.BANK_SYNC_ENABLED;
let enabled;
let enabled: boolean;
if (dbValue !== null && dbValue !== undefined && dbValue !== '') {
enabled = dbValue === 'true';
} else if (envValue !== undefined && envValue !== '') {
@ -51,13 +63,13 @@ function getBankSyncConfig() {
};
}
function setBankSyncEnabled(enabled) {
function setBankSyncEnabled(enabled: boolean): BankSyncConfig {
setSetting('bank_sync_enabled', enabled ? 'true' : 'false');
return getBankSyncConfig();
}
function setSyncIntervalHours(hours) {
const n = parseFloat(hours);
function setSyncIntervalHours(hours: number | string): BankSyncConfig {
const n = parseFloat(hours as string);
if (!Number.isFinite(n) || n < 0.5 || n > 168) {
throw Object.assign(new Error('sync_interval_hours must be between 0.5 and 168'), { status: 400 });
}
@ -65,8 +77,8 @@ function setSyncIntervalHours(hours) {
return getBankSyncConfig();
}
function setSyncDays(days) {
const n = parseInt(days, 10);
function setSyncDays(days: number | string): BankSyncConfig {
const n = parseInt(days as string, 10);
if (!Number.isFinite(n) || n < 1 || n > SYNC_DAYS_MAX) {
throw Object.assign(new Error(`sync_days must be between 1 and ${SYNC_DAYS_MAX} (SimpleFIN Bridge hard limit)`), { status: 400 });
}
@ -74,7 +86,7 @@ function setSyncDays(days) {
return getBankSyncConfig();
}
function setDebugLogging(enabled) {
function setDebugLogging(enabled: boolean): BankSyncConfig {
setSetting('simplefin_debug_logging', enabled ? 'true' : 'false');
return getBankSyncConfig();
}

View File

@ -8,7 +8,7 @@ const {
normalizeTransaction,
sanitizeErrorMessage,
} = require('./simplefinService');
const { getBankSyncConfig, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT } = require('./bankSyncConfigService');
const { getBankSyncConfig, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT } = require('./bankSyncConfigService.cts');
const { decorateDataSource } = require('./transactionService');
const { applyMerchantRules } = require('./billMerchantRuleService.cts');
const { applySpendingCategoryRules } = require('./spendingService.cts');

View File

@ -1,7 +1,7 @@
'use strict';
const { getDb } = require('../db/database');
const { getBankSyncConfig } = require('./bankSyncConfigService');
const { getBankSyncConfig } = require('./bankSyncConfigService.cts');
const { syncDataSource } = require('./bankSyncService');
// Skip a source if it was synced less than this long ago (catches recent manual syncs)

View File

@ -1,8 +1,11 @@
'use strict';
// import required so Node type-strips this .cts (it has no other import).
import type { Db } from '../types/db';
const crypto = require('crypto');
function parseBrowser(userAgent) {
function parseBrowser(userAgent: unknown): string {
const ua = String(userAgent || '');
if (/Edg\//i.test(ua)) return 'Edge';
if (/OPR\//i.test(ua) || /Opera/i.test(ua)) return 'Opera';
@ -14,7 +17,7 @@ function parseBrowser(userAgent) {
return 'Unknown';
}
function parseOs(userAgent) {
function parseOs(userAgent: unknown): string {
const ua = String(userAgent || '');
if (/iPhone|iPad|iPod/i.test(ua)) return 'iOS';
if (/Android/i.test(ua)) return 'Android';
@ -25,7 +28,7 @@ function parseOs(userAgent) {
return 'Unknown';
}
function parseDeviceType(userAgent) {
function parseDeviceType(userAgent: unknown): string {
const ua = String(userAgent || '');
if (/iPad|Tablet/i.test(ua)) return 'tablet';
if (/Mobi|iPhone|Android/i.test(ua)) return 'mobile';
@ -33,7 +36,7 @@ function parseDeviceType(userAgent) {
return 'desktop';
}
function coarseIpPrefix(ipAddress) {
function coarseIpPrefix(ipAddress: unknown): string {
const ip = String(ipAddress || '').trim();
if (!ip) return '';
@ -47,7 +50,7 @@ function coarseIpPrefix(ipAddress) {
return ip;
}
function buildDeviceFingerprint({ userAgent, ipAddress }) {
function buildDeviceFingerprint({ userAgent, ipAddress }: { userAgent?: unknown; ipAddress?: unknown }) {
const browser = parseBrowser(userAgent);
const os = parseOs(userAgent);
const deviceType = parseDeviceType(userAgent);

View File

@ -6,7 +6,7 @@ const {
markNotificationError,
markNotificationSuccess,
markNotificationTestSuccess,
} = require('./statusRuntime');
} = require('./statusRuntime.cts');
const { localDateString } = require('../utils/dates.mts');
const { fromCents } = require('../utils/money.mts');

View File

@ -1,5 +1,8 @@
'use strict';
// import required so Node type-strips this .cts (it has no other import).
import type { Db } from '../types/db';
const MAX_RECENT_ERRORS = 10;
// Keys written to the settings table for cross-restart persistence
@ -10,7 +13,18 @@ const DB_KEY = {
workerStarted: '_worker_started_at',
};
const state = {
interface WorkerState {
enabled: boolean;
running: boolean;
started_at: string | null;
last_run_at: string | null;
next_run_at: string | null;
last_error: string | null;
}
interface NotificationsState { last_test_at: string | null; last_error: string | null }
interface RuntimeError { timestamp: string; source: string; message: string }
const state: { worker: WorkerState; notifications: NotificationsState; recentErrors: RuntimeError[] } = {
worker: {
enabled: false,
running: false,
@ -28,7 +42,7 @@ const state = {
// Seed from DB on first load so status survives container restarts.
// Wrapped in try/catch — DB may not be ready at require-time in tests.
function seedFromDb() {
function seedFromDb(): void {
try {
const { getSetting } = require('../db/database');
state.worker.last_run_at = getSetting(DB_KEY.workerLastRun) || null;
@ -40,27 +54,27 @@ function seedFromDb() {
}
seedFromDb();
function dbSet(key, value) {
function dbSet(key: string, value: unknown): void {
try {
const { setSetting } = require('../db/database');
setSetting(key, value == null ? '' : String(value));
} catch { /* non-fatal */ }
}
function toMessage(error) {
function toMessage(error: unknown): string | null {
if (!error) return null;
if (typeof error === 'string') return error;
return error.message || String(error);
return (error as any).message || String(error);
}
function recordError(source, error) {
function recordError(source: string, error: unknown): void {
const message = toMessage(error);
if (!message) return;
state.recentErrors.unshift({ timestamp: new Date().toISOString(), source, message });
state.recentErrors = state.recentErrors.slice(0, MAX_RECENT_ERRORS);
}
function markWorkerStarted(nextRunAt = null) {
function markWorkerStarted(nextRunAt: string | null = null): void {
const now = new Date().toISOString();
state.worker.enabled = true;
state.worker.running = true;
@ -70,7 +84,7 @@ function markWorkerStarted(nextRunAt = null) {
if (nextRunAt) dbSet(DB_KEY.workerNextRun, nextRunAt);
}
function markWorkerSuccess(nextRunAt = null) {
function markWorkerSuccess(nextRunAt: string | null = null): void {
const now = new Date().toISOString();
state.worker.running = false;
state.worker.last_run_at = now;
@ -81,7 +95,7 @@ function markWorkerSuccess(nextRunAt = null) {
if (nextRunAt) dbSet(DB_KEY.workerNextRun, nextRunAt);
}
function markWorkerError(error, nextRunAt = null) {
function markWorkerError(error: unknown, nextRunAt: string | null = null): void {
const now = new Date().toISOString();
state.worker.running = false;
state.worker.last_run_at = now;
@ -93,17 +107,17 @@ function markWorkerError(error, nextRunAt = null) {
recordError('Daily Worker', error);
}
function markNotificationTestSuccess() {
function markNotificationTestSuccess(): void {
state.notifications.last_test_at = new Date().toISOString();
state.notifications.last_error = null;
}
function markNotificationError(error) {
function markNotificationError(error: unknown): void {
state.notifications.last_error = toMessage(error);
recordError('Notifications', error);
}
function markNotificationSuccess() {
function markNotificationSuccess(): void {
state.notifications.last_error = null;
}

View File

@ -11,7 +11,7 @@ const {
getTransactionForUser,
} = require('./transactionService');
const { serializePayment } = require('./paymentValidation.cts');
const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState');
const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState.cts');
const MATCH_PAYMENT_SOURCE = 'transaction_match';
const MATCH_PAYMENT_METHOD = 'transaction_match';

View File

@ -1,5 +1,7 @@
'use strict';
import type { Db } from '../types/db';
// Canonical writers for a transaction's match state.
//
// A transaction's match state lives in three columns that must move together:
@ -17,10 +19,10 @@
/**
* Mark a transaction matched to a bill.
* @param {boolean} [opts.resetIgnored] also clear the ignored flag (used when
* matching a transaction directly, which implicitly un-ignores it).
* @param resetIgnored also clear the ignored flag (used when matching a
* transaction directly, which implicitly un-ignores it).
*/
function markMatched(db, userId, transactionId, billId, { resetIgnored = false } = {}) {
function markMatched(db: Db, userId: number, transactionId: number, billId: number, { resetIgnored = false }: { resetIgnored?: boolean } = {}): number {
return db.prepare(`
UPDATE transactions
SET matched_bill_id = ?, match_status = 'matched'${resetIgnored ? ', ignored = 0' : ''}, updated_at = datetime('now')
@ -30,9 +32,9 @@ function markMatched(db, userId, transactionId, billId, { resetIgnored = false }
/**
* Clear a transaction's match — back to 'unmatched' with no bill.
* @param {boolean} [opts.resetIgnored] also clear the ignored flag (un-ignore).
* @param resetIgnored also clear the ignored flag (un-ignore).
*/
function markUnmatched(db, userId, transactionId, { resetIgnored = false } = {}) {
function markUnmatched(db: Db, userId: number, transactionId: number, { resetIgnored = false }: { resetIgnored?: boolean } = {}): number {
return db.prepare(`
UPDATE transactions
SET matched_bill_id = NULL, match_status = 'unmatched'${resetIgnored ? ', ignored = 0' : ''}, updated_at = datetime('now')
@ -41,7 +43,7 @@ function markUnmatched(db, userId, transactionId, { resetIgnored = false } = {})
}
/** Mark a transaction ignored — dropped from matching, no bill. */
function markIgnored(db, userId, transactionId) {
function markIgnored(db: Db, userId: number, transactionId: number): number {
return db.prepare(`
UPDATE transactions
SET ignored = 1, match_status = 'ignored', matched_bill_id = NULL, updated_at = datetime('now')

View File

@ -4,6 +4,9 @@
* 5 minutes for errors) so the status page stays fast under load.
*/
// import required so Node type-strips this .cts (it has no other import).
import type { Db } from '../types/db';
const { getSetting } = require('../db/database');
const REPO_API_BASE = process.env.REPO_API_URL
@ -12,7 +15,7 @@ const REPO_API_BASE = process.env.REPO_API_URL
// QA-B16-01: the version check is opt-out-able. Admins can disable the external
// request via the `update_check_enabled` setting (default on). When off, no
// network call is made and a `disabled` status is returned.
function updateCheckEnabled() {
function updateCheckEnabled(): boolean {
return getSetting('update_check_enabled') !== 'false';
}
@ -20,10 +23,10 @@ const TTL_OK_MS = 60 * 60 * 1000; // 1 hour on success
const TTL_ERROR_MS = 5 * 60 * 1000; // 5 min on error (avoid hammering)
const FETCH_TIMEOUT_MS = 8_000;
let _cache = { result: null, expiresAt: 0 };
let _pkg = null;
let _cache: { result: any; expiresAt: number } = { result: null, expiresAt: 0 };
let _pkg: any = null;
function getCurrentVersion() {
function getCurrentVersion(): string {
if (!_pkg) {
try { _pkg = require('../package.json'); } catch { _pkg = { version: '0.0.0' }; }
}
@ -31,8 +34,8 @@ function getCurrentVersion() {
}
// Returns positive if a > b, negative if a < b, 0 if equal.
function compareVersions(a, b) {
const parse = v => String(v).replace(/^v/, '').split('.').map(Number);
function compareVersions(a: string, b: string): number {
const parse = (v: string) => String(v).replace(/^v/, '').split('.').map(Number);
const pa = parse(a), pb = parse(b);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
@ -42,10 +45,10 @@ function compareVersions(a, b) {
}
/**
* @param {boolean} force Skip the cache and always hit the API.
* @returns {Promise<Object>} Update status object.
* @param force Skip the cache and always hit the API.
* @returns Update status object.
*/
async function checkForUpdates(force = false) {
async function checkForUpdates(force = false): Promise<any> {
const now = Date.now();
// Opt-out: no external request when disabled by the admin.
@ -99,7 +102,7 @@ async function checkForUpdates(force = false) {
if (!res.ok) throw new Error(`Forgejo API returned HTTP ${res.status}`);
const release = await res.json();
const release: any = await res.json();
const rawTag = release.tag_name || '';
const latestVersion = rawTag.replace(/^v/, '') || null;
const hasUpdate = latestVersion
@ -121,7 +124,7 @@ async function checkForUpdates(force = false) {
_cache = { result, expiresAt: now + TTL_OK_MS };
return result;
} catch (err) {
} catch (err: any) {
const result = {
current_version: currentVersion,
latest_version: null,

View File

@ -13,7 +13,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-match-state-${process.pid}.s
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { markMatched, markUnmatched, markIgnored } = require('../services/transactionMatchState');
const { markMatched, markUnmatched, markIgnored } = require('../services/transactionMatchState.cts');
let db, userId, otherId, billId;

View File

@ -13,7 +13,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-updatecheck-test-${process.p
process.env.DB_PATH = dbPath;
const { getDb, setSetting, closeDb } = require('../db/database');
const svc = require('../services/updateCheckService');
const svc = require('../services/updateCheckService.cts');
test('disabled: makes no external request and returns disabled', async () => {
getDb();

60
types/http.d.ts vendored Normal file
View File

@ -0,0 +1,60 @@
// Minimal permissive Express-ish request/response types for the migrated routes
// and middleware. @types/express is not installed and the handlers are dynamic
// (req.user is added by requireAuth, req.body/query are user input), so these
// carry an index signature — typed enough for `.json()/.status()` chaining and
// the common fields, permissive everywhere else. Swap for @types/express +
// declaration-merged `user` if the routes ever warrant full typing.
export interface Req {
body: any;
params: any;
query: any;
user?: any;
ip?: string;
method: string;
path?: string;
originalUrl?: string;
headers: any;
cookies?: any;
signedCookies?: any;
secure?: boolean;
csrfSkip?: boolean;
file?: any;
files?: any;
get(name: string): string | undefined;
header(name: string): string | undefined;
[k: string]: any;
}
export interface Res {
json(body?: any): Res;
status(code: number): Res;
send(body?: any): Res;
end(body?: any): Res;
cookie(name: string, value: any, options?: any): Res;
clearCookie(name: string, options?: any): Res;
setHeader(name: string, value: any): void;
getHeader(name: string): any;
set(field: string, value?: any): Res;
type(t: string): Res;
redirect(url: string): void;
download(path: string, filename?: string, cb?: (err?: any) => void): void;
sendFile(path: string, options?: any, cb?: (err?: any) => void): void;
attachment(filename?: string): Res;
locals: any;
headersSent: boolean;
[k: string]: any;
}
export type Next = (err?: any) => void;
// An Express router (the shape `express.Router()` returns) — permissive.
export interface Router {
get(path: string, ...handlers: any[]): void;
post(path: string, ...handlers: any[]): void;
put(path: string, ...handlers: any[]): void;
patch(path: string, ...handlers: any[]): void;
delete(path: string, ...handlers: any[]): void;
use(...handlers: any[]): void;
[k: string]: any;
}

View File

@ -1,6 +1,6 @@
/**
* Centralized error handling utility for API routes
*
*
* Standard error format:
* {
* error: 'ErrorType',
@ -10,14 +10,21 @@
* }
*/
import type { Req, Res, Next } from '../types/http';
class ApiError extends Error {
constructor(code, message, status = 500, details = {}) {
code: string;
status: number;
details: any;
field?: string;
constructor(code: string, message: string, status = 500, details: any = {}) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
this.details = details;
// Extract field name from details if provided
if (details.field) {
this.field = details.field;
@ -28,42 +35,42 @@ class ApiError extends Error {
/**
* Create a standardized validation error
*/
function ValidationError(message, field, code = 'VALIDATION_ERROR') {
function ValidationError(message: string, field?: string, code = 'VALIDATION_ERROR'): ApiError {
return new ApiError(code, message, 400, { field });
}
/**
* Create a standardized authentication error
*/
function AuthError(message = 'Authentication required', code = 'AUTH_ERROR') {
function AuthError(message = 'Authentication required', code = 'AUTH_ERROR'): ApiError {
return new ApiError(code, message, 401);
}
/**
* Create a standardized authorization error
*/
function ForbiddenError(message = 'Access denied', code = 'FORBIDDEN') {
function ForbiddenError(message = 'Access denied', code = 'FORBIDDEN'): ApiError {
return new ApiError(code, message, 403);
}
/**
* Create a standardized not found error
*/
function NotFoundError(message = 'Resource not found', code = 'NOT_FOUND') {
function NotFoundError(message = 'Resource not found', code = 'NOT_FOUND'): ApiError {
return new ApiError(code, message, 404);
}
/**
* Create a standardized conflict error
*/
function ConflictError(message = 'Resource conflict', code = 'CONFLICT') {
function ConflictError(message = 'Resource conflict', code = 'CONFLICT'): ApiError {
return new ApiError(code, message, 409);
}
/**
* Create a standardized rate limit error
*/
function RateLimitError(message = 'Too many requests', code = 'RATE_LIMITED') {
function RateLimitError(message = 'Too many requests', code = 'RATE_LIMITED'): ApiError {
return new ApiError(code, message, 429);
}
@ -71,21 +78,21 @@ function RateLimitError(message = 'Too many requests', code = 'RATE_LIMITED') {
* Format an error for JSON response
* This ensures consistent error format across all routes
*/
function formatError(err) {
function formatError(err: any): { error: string; message: string; field?: string } {
if (err instanceof ApiError) {
const output = {
const output: { error: string; message: string; field?: string } = {
error: err.code,
message: err.message,
};
if (err.field) output.field = err.field;
return output;
}
// Fallback for non-standard errors (log internally, show safe message)
const safeMessage = err.status === 500
const safeMessage = err.status === 500
? 'Internal server error'
: err.message || 'An error occurred';
return {
error: err.code || 'ERROR',
message: safeMessage,
@ -95,15 +102,15 @@ function formatError(err) {
/**
* Express middleware to handle errors centrally
*/
function errorHandler(err, req, res, next) {
function errorHandler(err: any, req: Req, res: Res, next: Next): void {
const formatted = formatError(err);
const status = err.status || (err instanceof ApiError ? 500 : 500);
// Only log non-500 errors to avoid exposing sensitive info
if (status !== 500) {
console.error(`[error] ${formatted.error}: ${formatted.message}`);
}
if (!res.headersSent) {
res.status(status).json(formatted);
}

View File

@ -11,7 +11,7 @@ const {
markWorkerError,
markWorkerStarted,
markWorkerSuccess,
} = require('../services/statusRuntime');
} = require('../services/statusRuntime.cts');
const { localDateString, localDateStringDaysAgo } = require('../utils/dates.mts');
// The hour (023) the single daily job runs — configurable via the global