diff --git a/routes/admin.cts b/routes/admin.cts index e85f758..04fb7e9 100644 --- a/routes/admin.cts +++ b/routes/admin.cts @@ -234,10 +234,14 @@ router.put('/users/:id/password', async (req: Req, res: Res) => { if (!user) throw NotFoundError('User not found'); const hash = await hashPassword(password); - db.prepare( - "UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?", - ).run(hash, targetId); - db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId); + // Password update + session invalidation land together: a failure between + // them would leave a reset password with the old sessions still live. + db.transaction(() => { + db.prepare( + "UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?", + ).run(hash, targetId); + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId); + })(); logAudit({ user_id: req.user.id, diff --git a/routes/auth.cts b/routes/auth.cts index 8004900..924eeab 100644 --- a/routes/auth.cts +++ b/routes/auth.cts @@ -27,6 +27,7 @@ const { invalidateOtherSessions, recordLogin, recordFailedLogin, + createSession, } = require('../services/authService.cts'); const { decryptSecret } = require('../services/encryptionService.cts'); const { getCsrfToken } = require('../middleware/csrf.cts'); @@ -283,7 +284,6 @@ router.post('/totp/challenge', async (req: Req, res: Res) => { throw AuthError('Invalid authenticator code.'); } - const { createSession } = require('../services/authService.cts'); const session = await createSession(userId); if (!session) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500); @@ -586,10 +586,14 @@ router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => { if (!(await bcrypt.compare(password, user.password_hash))) throw AuthError('Password is incorrect'); - db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id); - db.prepare( - "UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?", - ).run(req.user.id); + // Credential delete + flag clear land together: a failure between them would + // leave webauthn_enabled=1 with zero credentials (the stale-flag state). + db.transaction(() => { + db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id); + db.prepare( + "UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?", + ).run(req.user.id); + })(); logAudit({ user_id: req.user.id, @@ -632,7 +636,6 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => { throw AuthError('Security key verification failed.'); } - const { createSession } = require('../services/authService.cts'); const s = await createSession(session.userId); if (!s) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500); diff --git a/routes/payments.cts b/routes/payments.cts index a40069f..3261c4a 100644 --- a/routes/payments.cts +++ b/routes/payments.cts @@ -1,12 +1,7 @@ // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). import type { Req, Res, Next } from '../types/http'; const express = require('express'); -const { - ApiError, - ValidationError, - NotFoundError, - ConflictError, -} = require('../utils/apiError.cts'); +const { ValidationError, NotFoundError, ConflictError } = require('../utils/apiError.cts'); const router = require('express').Router(); const { getDb } = require('../db/database.cts'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts'); diff --git a/routes/snowball.cts b/routes/snowball.cts index 2704a74..01132e1 100644 --- a/routes/snowball.cts +++ b/routes/snowball.cts @@ -417,22 +417,25 @@ router.post('/plans', (req: Req, res: Res) => { debts: debtSnaps, }); - // Abandon any existing active/paused plan first - db.prepare( - ` + // Abandon-existing + insert-new land together or neither: a failure between + // them must not leave the user with no active plan. + const result = db.transaction(() => { + db.prepare( + ` UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now') WHERE user_id = ? AND status IN ('active', 'paused') `, - ).run(userId); + ).run(userId); - const result = db - .prepare( - ` + return db + .prepare( + ` INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now')) `, - ) - .run(userId, planName, planMethod, extraCents, planSnapshot, notes || null); + ) + .run(userId, planName, planMethod, extraCents, planSnapshot, notes || null); + })(); const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid); res.status(201).json(enrichPlanWithProgress(db, plan)); @@ -521,7 +524,6 @@ router.post('/plans/:id/pause', (req: Req, res: Res) => transitionPlan(req, res, { allowedFrom: ['active'], setSql: "status = 'paused', paused_at = datetime('now')", - action: 'pause', past: 'paused', }), ); @@ -529,7 +531,6 @@ router.post('/plans/:id/resume', (req: Req, res: Res) => transitionPlan(req, res, { allowedFrom: ['paused'], setSql: "status = 'active', paused_at = NULL", - action: 'resume', past: 'resumed', }), ); @@ -537,7 +538,6 @@ router.post('/plans/:id/complete', (req: Req, res: Res) => transitionPlan(req, res, { allowedFrom: ['active', 'paused'], setSql: "status = 'completed', completed_at = datetime('now')", - action: 'complete', past: 'completed', }), ); @@ -545,7 +545,6 @@ router.post('/plans/:id/abandon', (req: Req, res: Res) => transitionPlan(req, res, { allowedFrom: ['active', 'paused'], setSql: "status = 'abandoned'", - action: 'abandon', past: 'abandoned', }), );