Initial Wave of Updates

This commit is contained in:
Brian Fertig 2026-06-16 14:25:22 -06:00
parent 7e9406a8ae
commit 53b8f497cb
11 changed files with 247 additions and 11 deletions

50
package-lock.json generated
View File

@ -19,7 +19,8 @@
"devDependencies": { "devDependencies": {
"alpinejs": "^3.14.1", "alpinejs": "^3.14.1",
"beercss": "^3.7.0", "beercss": "^3.7.0",
"material-dynamic-colors": "^1.1.2" "material-dynamic-colors": "^1.1.2",
"playwright": "^1.61.0"
} }
}, },
"node_modules/@material/material-color-utilities": { "node_modules/@material/material-color-utilities": {
@ -577,6 +578,21 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -949,6 +965,38 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",

View File

@ -21,6 +21,7 @@
"devDependencies": { "devDependencies": {
"alpinejs": "^3.14.1", "alpinejs": "^3.14.1",
"beercss": "^3.7.0", "beercss": "^3.7.0",
"material-dynamic-colors": "^1.1.2" "material-dynamic-colors": "^1.1.2",
"playwright": "^1.61.0"
} }
} }

View File

@ -7,7 +7,11 @@
<link rel="stylesheet" href="/vendor/beer.min.css" /> <link rel="stylesheet" href="/vendor/beer.min.css" />
<link rel="stylesheet" href="/css/app.css" /> <link rel="stylesheet" href="/css/app.css" />
<script src="/vendor/material-dynamic-colors.min.js"></script> <script src="/vendor/material-dynamic-colors.min.js"></script>
<script src="/vendor/beer.min.js"></script> <!-- Beer CSS is an ES module that only exports `ui`; expose it as the global our code calls. -->
<script type="module">
import { ui } from '/vendor/beer.min.js';
window.ui = ui;
</script>
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/theme.js"></script> <script src="/js/theme.js"></script>
<script src="/js/admin.js" defer></script> <script src="/js/admin.js" defer></script>

View File

@ -7,7 +7,11 @@
<link rel="stylesheet" href="/vendor/beer.min.css" /> <link rel="stylesheet" href="/vendor/beer.min.css" />
<link rel="stylesheet" href="/css/app.css" /> <link rel="stylesheet" href="/css/app.css" />
<script src="/vendor/material-dynamic-colors.min.js"></script> <script src="/vendor/material-dynamic-colors.min.js"></script>
<script src="/vendor/beer.min.js"></script> <!-- Beer CSS is an ES module that only exports `ui`; expose it as the global our code calls. -->
<script type="module">
import { ui } from '/vendor/beer.min.js';
window.ui = ui;
</script>
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/theme.js"></script> <script src="/js/theme.js"></script>
<script src="/js/app.js" defer></script> <script src="/js/app.js" defer></script>

View File

@ -23,6 +23,14 @@ window.API = (function () {
try { data = JSON.parse(text); } catch (_) { data = { raw: text }; } try { data = JSON.parse(text); } catch (_) { data = { raw: text }; }
} }
if (!res.ok) { if (!res.ok) {
// An expired/invalid session (401) on any call other than the login POST
// means we should send the user back to sign in, rather than leaving the
// page in a broken half-loaded state with only a console error.
const onLoginPage = window.location.pathname.endsWith('/login.html');
const isLoginCall = url.endsWith('/api/login');
if (res.status === 401 && !onLoginPage && !isLoginCall) {
window.location.href = '/login.html';
}
const err = new Error((data && data.error) || `Request failed (${res.status})`); const err = new Error((data && data.error) || `Request failed (${res.status})`);
err.status = res.status; err.status = res.status;
throw err; throw err;

View File

@ -7,7 +7,11 @@
<link rel="stylesheet" href="/vendor/beer.min.css" /> <link rel="stylesheet" href="/vendor/beer.min.css" />
<link rel="stylesheet" href="/css/app.css" /> <link rel="stylesheet" href="/css/app.css" />
<script src="/vendor/material-dynamic-colors.min.js"></script> <script src="/vendor/material-dynamic-colors.min.js"></script>
<script src="/vendor/beer.min.js"></script> <!-- Beer CSS is an ES module that only exports `ui`; expose it as the global our code calls. -->
<script type="module">
import { ui } from '/vendor/beer.min.js';
window.ui = ui;
</script>
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/theme.js"></script> <script src="/js/theme.js"></script>
</head> </head>

View File

@ -11,6 +11,12 @@ const OUT = path.join(ROOT, 'public', 'vendor');
const FILES = [ const FILES = [
['beercss/dist/cdn/beer.min.css', 'beer.min.css'], ['beercss/dist/cdn/beer.min.css', 'beer.min.css'],
['beercss/dist/cdn/beer.min.js', 'beer.min.js'], ['beercss/dist/cdn/beer.min.js', 'beer.min.js'],
// Material Symbols icon fonts. beer.min.css references these by relative URL
// (with a CDN fallback our CSP blocks), so they must sit beside it in /vendor.
['beercss/dist/cdn/material-symbols-outlined.woff2', 'material-symbols-outlined.woff2'],
['beercss/dist/cdn/material-symbols-rounded.woff2', 'material-symbols-rounded.woff2'],
['beercss/dist/cdn/material-symbols-sharp.woff2', 'material-symbols-sharp.woff2'],
['beercss/dist/cdn/material-symbols-subset.woff2', 'material-symbols-subset.woff2'],
['material-dynamic-colors/dist/cdn/material-dynamic-colors.min.js', 'material-dynamic-colors.min.js'], ['material-dynamic-colors/dist/cdn/material-dynamic-colors.min.js', 'material-dynamic-colors.min.js'],
['alpinejs/dist/cdn.min.js', 'alpine.min.js'], ['alpinejs/dist/cdn.min.js', 'alpine.min.js'],
]; ];
@ -29,5 +35,35 @@ for (const [src, dest] of FILES) {
console.error(`FAILED to vendor ${src}: ${e.message}`); console.error(`FAILED to vendor ${src}: ${e.message}`);
} }
} }
// Strip Beer CSS's remote @font-face fallbacks (jsdelivr CDN) so the icon font
// resolves only to our local /vendor copy — no CDN load, no CSP violation.
const beerCss = path.join(OUT, 'beer.min.css');
try {
const before = fs.readFileSync(beerCss, 'utf8');
const after = before.replace(/,url\(https:\/\/cdn\.jsdelivr\.net[^)]*\)\s*format\("woff2"\)/g, '');
if (after !== before) {
fs.writeFileSync(beerCss, after);
console.log('stripped remote CDN font fallbacks from beer.min.css');
}
} catch (e) {
console.error(`WARN: could not post-process beer.min.css: ${e.message}`);
}
// material-dynamic-colors ships as an ES module: it sets the
// globalThis.materialDynamicColors global (which Beer CSS's ui("theme") needs)
// but ALSO ends with `export default ...`, which breaks classic <script> loading
// ("Unexpected token 'export'"). Strip the export so it loads as a plain script.
const mdc = path.join(OUT, 'material-dynamic-colors.min.js');
try {
const before = fs.readFileSync(mdc, 'utf8');
const after = before.replace(/\s*export\s+default[^;]*;\s*$/, '\n');
if (after !== before) {
fs.writeFileSync(mdc, after);
console.log('stripped ES export from material-dynamic-colors.min.js');
}
} catch (e) {
console.error(`WARN: could not post-process material-dynamic-colors.min.js: ${e.message}`);
}
console.log(`\n${ok}/${FILES.length} files vendored into public/vendor`); console.log(`\n${ok}/${FILES.length} files vendored into public/vendor`);
if (ok < FILES.length) process.exitCode = 1; if (ok < FILES.length) process.exitCode = 1;

View File

@ -23,7 +23,10 @@ app.use(
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Alpine.js compiles its x-/@/: expressions with new Function(), which
// requires 'unsafe-eval'. All scripts are self-hosted/vendored (no
// remote or user-injected scripts), so the added risk is minimal.
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'], imgSrc: ["'self'", 'data:'],
fontSrc: ["'self'", 'data:'], fontSrc: ["'self'", 'data:'],
@ -50,6 +53,14 @@ app.use(
}) })
); );
// Never let the browser cache API responses. Without this, a GET like /api/me
// can be served stale from cache (e.g. a 200 from a prior session) while other
// calls hit the network and 401 on an expired session — confusing and buggy.
app.use('/api', (req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
// API routes // API routes
app.use('/api', require('./src/routes/auth.routes')); app.use('/api', require('./src/routes/auth.routes'));
app.use('/api/users', require('./src/routes/users.routes')); app.use('/api/users', require('./src/routes/users.routes'));
@ -59,8 +70,20 @@ app.use('/api/projects', require('./src/routes/projects.routes'));
app.use('/api/kpis', require('./src/routes/kpis.routes')); app.use('/api/kpis', require('./src/routes/kpis.routes'));
app.use('/api/entries', require('./src/routes/entries.routes')); app.use('/api/entries', require('./src/routes/entries.routes'));
// Static frontend // Silence the browser's automatic /favicon.ico request (otherwise a 404 shows
app.use(express.static(path.join(__dirname, 'public'))); // up in the console as a scary-looking "Failed to load resource").
app.get('/favicon.ico', (req, res) => res.status(204).end());
// Static frontend. Force revalidation of HTML/JS so the browser never runs a
// stale cached build after an update (a frequent source of "I fixed it but it
// still breaks" confusion). Fonts/CSS may still be cached via ETag/Last-Modified.
app.use(
express.static(path.join(__dirname, 'public'), {
setHeaders(res, filePath) {
if (/\.(html|js)$/i.test(filePath)) res.set('Cache-Control', 'no-cache');
},
})
);
// JSON 404 for unmatched API routes // JSON 404 for unmatched API routes
app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' })); app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' }));

View File

@ -19,9 +19,10 @@ const countSchemes = db.prepare('SELECT COUNT(*) AS n FROM color_schemes');
const HEX = /^#[0-9a-fA-F]{6}$/; const HEX = /^#[0-9a-fA-F]{6}$/;
// GET /api/schemes is readable by any logged-in user (the picker needs it); // GET /api/schemes is public: schemes are just non-sensitive color definitions,
// writes are admin-only. // and the login page themes itself from them before anyone authenticates.
router.get('/', requireAuth, (req, res) => res.json({ schemes: listSchemes.all() })); // Writes (below) are admin-only.
router.get('/', (req, res) => res.json({ schemes: listSchemes.all() }));
router.use(requireAuth, requireAdmin); router.use(requireAuth, requireAdmin);

69
tests/browser-check.js Normal file
View File

@ -0,0 +1,69 @@
'use strict';
// Drives the app in a real browser (Edge) and reports console errors, page
// errors, and all network responses so we can see exactly what the browser
// experiences that curl-style tests don't.
const { chromium } = require('playwright');
const BASE = 'http://localhost:3000';
const USER = process.env.ADMIN_USERNAME || 'admin';
const PASS = process.env.ADMIN_PASSWORD || 'BowlerAdmin!2026';
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const log = [];
page.on('console', (m) => log.push(` [console.${m.type()}] ${m.text()}`));
page.on('pageerror', (e) => log.push(` [pageerror] ${e.message}`));
page.on('requestfailed', (r) =>
log.push(` [requestfailed] ${r.method()} ${r.url()} :: ${r.failure() && r.failure().errorText}`)
);
page.on('response', (r) => {
const u = r.url();
if (u.includes('/api/')) log.push(` [response] ${r.status()} ${r.request().method()} ${u}`);
});
function section(t) { log.push(`\n=== ${t} ===`); }
section('1) GET / (fresh, no cookies)');
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' });
log.push(` landed on: ${page.url()}`);
section('2) Go to /login.html and sign in');
if (!page.url().includes('login.html')) await page.goto(`${BASE}/login.html`, { waitUntil: 'networkidle' });
await page.fill('#username', USER);
await page.fill('#password', PASS);
await page.click('#login-form button[type="submit"]');
await page.waitForLoadState('networkidle');
log.push(` after login, url: ${page.url()}`);
// Inspect cookies the browser is holding.
const cookies = await context.cookies();
log.push(` cookies: ${cookies.map((c) => `${c.name}(sameSite=${c.sameSite},secure=${c.secure},httpOnly=${c.httpOnly})`).join(', ') || 'NONE'}`);
section('3) On index — wait for chart fetch');
await page.waitForTimeout(1200);
log.push(` url: ${page.url()}`);
section('4) Click Admin button');
const adminBtn = page.locator('button[title="Admin"]');
if (await adminBtn.count()) {
await adminBtn.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
log.push(` url after admin click: ${page.url()}`);
// Did users/schemes render?
const userRows = await page.locator('table tbody tr').count().catch(() => -1);
log.push(` admin table rows visible: ${userRows}`);
} else {
log.push(' Admin button NOT found (not admin, or page not loaded)');
}
console.log(log.join('\n'));
await browser.close();
})().catch((e) => {
console.error('SCRIPT ERROR:', e);
process.exit(1);
});

38
tests/stale-check.js Normal file
View File

@ -0,0 +1,38 @@
'use strict';
// Reproduces a user with a STALE/invalid session cookie and confirms the app
// now redirects to login gracefully instead of dead-ending on 401 errors.
const { chromium } = require('playwright');
const BASE = 'http://localhost:3000';
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const log = [];
async function scenario(name, urlPath, setBadCookie) {
const context = await browser.newContext();
if (setBadCookie) {
await context.addCookies([
{ name: 'connect.sid', value: 's%3Abogus-stale-session-id.signature', url: BASE },
]);
}
const page = await context.newPage();
const errs = [];
page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); });
page.on('pageerror', (e) => errs.push('pageerror: ' + e.message));
await page.goto(`${BASE}${urlPath}`, { waitUntil: 'networkidle' });
await page.waitForTimeout(800);
log.push(`\n=== ${name} ===`);
log.push(` start: ${urlPath} (badCookie=${!!setBadCookie})`);
log.push(` ended on: ${page.url()}`);
log.push(` console errors: ${errs.length ? errs.join(' | ') : 'none'}`);
await context.close();
}
await scenario('Stale cookie visits /', '/', true);
await scenario('Stale cookie opens /admin.html directly', '/admin.html', true);
await scenario('No session opens /admin.html directly', '/admin.html', false);
console.log(log.join('\n'));
await browser.close();
})().catch((e) => { console.error('SCRIPT ERROR:', e); process.exit(1); });