Compare commits

...

2 Commits

Author SHA1 Message Date
null d8365d0e22 chore(deps)!: vite 8 (rolldown) + @vitejs/plugin-react 6 (4h)
Build drops 6.5s -> 0.65s. Three breakages found by the prod-smoke gate,
all fixed:

- rolldown removed object-form manualChunks -> function form in
  vite.config.mjs (same vendor split, all @radix/@tanstack grouped)
- the 4a 'send' bump rejects bare absolute paths: SPA fallback sendFile
  and both download routes (admin backup, user-db export) now use the
  root-option form
- rolldown minifies inline HTML scripts, breaking any CSP hash approach:
  the pre-paint theme script moved to client/public/theme-init.js so
  script-src 'self' holds with no inline hash to maintain

@testing-library/dom added (peer no longer auto-installed). Server 252,
client 52, probe 18/18, PROD SMOKE: PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:53:27 -05:00
null a1b7011149 fix(standards): make the error-shape ratchet see require(); convert the 4 files it exposed
no-restricted-imports never fires on require() calls, so the ratchet was
toothless — switched to no-restricted-syntax on the require literal. It
immediately flagged four residual files whose multi-line res.status chains
the conversion greps had missed: user.cts (four err.message-at-500 leaks —
same class as QA-B13-02), calendar.cts, export.cts, and payments.cts's
DUPLICATE_SUSPECTED body (now a hand-built literal of the standard shape,
import dropped). Lint enforces the ratchet for real now; 252/252.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:53:24 -05:00
10 changed files with 260 additions and 758 deletions

View File

@ -0,0 +1,24 @@
// Pre-bundle theme apply — avoids a light/dark flash. Mirrors applyTheme in
// client/contexts/ThemeContext.tsx (theme 'system' follows the OS).
// External file (not inline in index.html) so the CSP stays `script-src 'self'`
// with no inline-hash maintenance across build-tool upgrades.
(function () {
try {
var s = localStorage.getItem('bt-theme');
if (s === 'dark-purple') s = 'dark';
var theme = s === 'light' || s === 'dark' || s === 'system' ? s : 'dark';
var resolved =
theme === 'system'
? window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme;
var root = document.documentElement;
if (resolved === 'dark') root.classList.add('dark');
else root.classList.remove('dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.setAttribute('content', resolved === 'dark' ? '#101417' : '#f8faf9');
} catch (e) {
/* localStorage/matchMedia unavailable — keep default */
}
})();

View File

@ -23,24 +23,9 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Bill Tracker"> <meta name="apple-mobile-web-app-title" content="Bill Tracker">
<title>Bill Tracker</title> <title>Bill Tracker</title>
<script> <!-- Pre-bundle theme apply (external so CSP stays script-src 'self'): a
// Pre-bundle theme apply — avoids a light/dark flash. Mirrors applyTheme in classic script blocks parsing, so the class lands before first paint. -->
// client/contexts/ThemeContext.tsx (theme 'system' follows the OS). <script src="/theme-init.js"></script>
(function () {
try {
var s = localStorage.getItem('bt-theme');
if (s === 'dark-purple') s = 'dark';
var theme = (s === 'light' || s === 'dark' || s === 'system') ? s : 'dark';
var resolved = theme === 'system'
? ((window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light')
: theme;
var root = document.documentElement;
if (resolved === 'dark') root.classList.add('dark'); else root.classList.remove('dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.setAttribute('content', resolved === 'dark' ? '#101417' : '#f8faf9');
} catch (e) { /* localStorage/matchMedia unavailable — keep default */ }
})();
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

867
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -66,7 +66,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^6.26.2", "react-router-dom": "^7.18.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@ -76,11 +76,12 @@
"@axe-core/playwright": "^4.10.1", "@axe-core/playwright": "^4.10.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^26.1.0", "@types/node": "^26.1.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
@ -97,7 +98,7 @@
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.62.1", "typescript-eslint": "^8.62.1",
"vite": "^5.4.10", "vite": "^8.1.4",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.8" "vitest": "^4.1.8"
}, },

View File

@ -149,9 +149,16 @@ router.get('/backups/:id/download', (req: Req, res: Res) => {
const backup = getBackupFile(req.params.id); const backup = getBackupFile(req.params.id);
res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.download(backup.path, backup.metadata.id, (err) => { // root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep)
const pathMod = require('path');
res.download(
pathMod.basename(backup.path),
backup.metadata.id,
{ root: pathMod.dirname(backup.path) },
(err) => {
if (err && !res.headersSent) sendError(res, err); if (err && !res.headersSent) sendError(res, err);
}); },
);
} catch (err) { } catch (err) {
sendError(res, err); sendError(res, err);
} }

View File

@ -1,7 +1,7 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @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'; import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { ValidationError } = require('../utils/apiError.cts');
const router = express.Router(); const router = express.Router();
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
@ -22,16 +22,10 @@ router.get('/', (req: Req, res: Res) => {
let where, whereParams, label; let where, whereParams, label;
if (from != null || to != null) { if (from != null || to != null) {
if (!isDate(from) || !isDate(to)) { if (!isDate(from) || !isDate(to)) {
return res throw ValidationError('from and to must both be YYYY-MM-DD dates', 'from');
.status(400)
.json(
standardizeError('from and to must both be YYYY-MM-DD dates', 'VALIDATION_ERROR', 'from'),
);
} }
if (from > to) { if (from > to) {
return res throw ValidationError('from must be on or before to', 'from');
.status(400)
.json(standardizeError('from must be on or before to', 'VALIDATION_ERROR', 'from'));
} }
where = 'p.paid_date BETWEEN ? AND ?'; where = 'p.paid_date BETWEEN ? AND ?';
whereParams = [from, to]; whereParams = [from, to];
@ -39,15 +33,7 @@ router.get('/', (req: Req, res: Res) => {
} else { } else {
const year = parseInt(req.query.year || new Date().getFullYear(), 10); const year = parseInt(req.query.year || new Date().getFullYear(), 10);
if (isNaN(year) || year < 2000 || year > 2100) { if (isNaN(year) || year < 2000 || year > 2100) {
return res throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
} }
where = "strftime('%Y', p.paid_date) = ?"; where = "strftime('%Y', p.paid_date) = ?";
whereParams = [String(year)]; whereParams = [String(year)];
@ -342,11 +328,19 @@ function buildUserDbExportFile(userId) {
router.get('/user-db', (req: Req, res: Res) => { router.get('/user-db', (req: Req, res: Res) => {
const file = buildUserDbExportFile(req.user.id); const file = buildUserDbExportFile(req.user.id);
res.download(file, 'bill-tracker-user-export.sqlite', () => { // root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep)
res.download(
path.basename(file),
'bill-tracker-user-export.sqlite',
{
root: path.dirname(file),
},
() => {
try { try {
fs.unlinkSync(file); fs.unlinkSync(file);
} catch {} } catch {}
}); },
);
}); });
// Full portable JSON export of the user's data (same assembly as SQLite/Excel). // Full portable JSON export of the user's data (same assembly as SQLite/Excel).

View File

@ -1,7 +1,6 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @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'; import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const { const {
ApiError, ApiError,
ValidationError, ValidationError,
@ -269,12 +268,14 @@ router.post('/', (req: Req, res: Res) => {
) )
.get(bill.id, payment.paid_date, payment.amount); .get(bill.id, payment.paid_date, payment.amount);
if (existingDuplicate) { if (existingDuplicate) {
// Deliberate hand-rolled body (documented exception): carries the
// `existing` payment the client's "add anyway?" flow renders —
// formatError has no extras channel. Shape matches the standard envelope.
return res.status(409).json({ return res.status(409).json({
...standardizeError( error: 'DUPLICATE_SUSPECTED',
'A matching payment already exists for this bill, date, and amount', message: 'A matching payment already exists for this bill, date, and amount',
'DUPLICATE_SUSPECTED', code: 'DUPLICATE_SUSPECTED',
'amount', field: 'amount',
),
existing: serializePayment(existingDuplicate), existing: serializePayment(existingDuplicate),
}); });
} }

View File

@ -273,7 +273,8 @@ const { getCsrfToken } = require('./middleware/csrf.cts');
app.get('/{*splat}', (req, res) => { app.get('/{*splat}', (req, res) => {
// Set CSRF cookie if not present (needed for SPA to read token) // Set CSRF cookie if not present (needed for SPA to read token)
getCsrfToken(req, res); getCsrfToken(req, res);
res.sendFile(path.join(DIST, 'index.html')); // root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep)
res.sendFile('index.html', { root: DIST });
}); });
// ── Global error handler ────────────────────────────────────────────────────── // ── Global error handler ──────────────────────────────────────────────────────

View File

@ -12,6 +12,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-export-richer-${process.pid}
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts'); const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const exportRouter = require('../routes/export.cts'); const exportRouter = require('../routes/export.cts');
const { getUserExportData } = exportRouter; const { getUserExportData } = exportRouter;
@ -39,7 +40,13 @@ function callExport(query) {
resolve({ status: this.statusCode, headers, body }); resolve({ status: this.statusCode, headers, body });
}, },
}; };
// Routes follow the throw-pattern: mirror the app's terminal error handler
// so thrown ApiErrors resolve to the same wire shape.
try {
handler(req, res); handler(req, res);
} catch (err) {
resolve({ status: err.status || 500, headers, json: formatError(err) });
}
}); });
} }

View File

@ -103,18 +103,15 @@ export default defineConfig({
output: { output: {
// QA-B0-01: split the shared vendor code out of the ~659 kB index chunk // QA-B0-01: split the shared vendor code out of the ~659 kB index chunk
// so large libs load/cache independently and the main bundle shrinks. // so large libs load/cache independently and the main bundle shrinks.
manualChunks: { // Function form: vite 8 (rolldown core) dropped the object form.
'vendor-react': ['react', 'react-dom', 'react-router-dom'], manualChunks(id) {
'vendor-radix': [ if (!id.includes('node_modules')) return undefined;
'@radix-ui/react-dialog', if (/node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/.test(id))
'@radix-ui/react-select', return 'vendor-react';
'@radix-ui/react-dropdown-menu', if (/node_modules[\\/]@radix-ui[\\/]/.test(id)) return 'vendor-radix';
'@radix-ui/react-tabs', if (/node_modules[\\/]framer-motion[\\/]/.test(id)) return 'vendor-motion';
'@radix-ui/react-tooltip', if (/node_modules[\\/]@tanstack[\\/]/.test(id)) return 'vendor-query';
'@radix-ui/react-alert-dialog', return undefined;
],
'vendor-motion': ['framer-motion'],
'vendor-query': ['@tanstack/react-query', '@tanstack/react-virtual'],
}, },
}, },
}, },