fix(integrity): transaction-wrap three multi-statement writes + review cleanups
CODE_STANDARDS 'every multi-statement write runs inside db.transaction()' hits from the review: snowball plan create (abandon-existing + insert), webauthn disable (credential delete + flag clear — a failure between them recreates the stale-flag state), admin password reset (hash update + session invalidation). Cleanups: dead ApiError import (payments), dead action: params on the four snowball transition callers, createSession consolidated into auth.cts's top-level import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2a07e762a2
commit
a17ce13f07
|
|
@ -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);
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
// 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -417,7 +417,9 @@ router.post('/plans', (req: Req, res: Res) => {
|
|||
debts: debtSnaps,
|
||||
});
|
||||
|
||||
// Abandon any existing active/paused plan first
|
||||
// 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')
|
||||
|
|
@ -425,7 +427,7 @@ router.post('/plans', (req: Req, res: Res) => {
|
|||
`,
|
||||
).run(userId);
|
||||
|
||||
const result = db
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at)
|
||||
|
|
@ -433,6 +435,7 @@ router.post('/plans', (req: Req, res: Res) => {
|
|||
`,
|
||||
)
|
||||
.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',
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue