mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
From the regression sweep. The first is a genuine regression against main.
A RATE-LIMITED DISCOVERY PERMANENTLY DEAD-ENDED THE LOGIN PAGE
lookupOrgSso checked that a body PARSED, not that the request succeeded — and a 429
body is valid JSON. So `data.sso` came back undefined, the single sign-on button was
hidden, the password box restored, and the domain recorded as answered: permanently,
for the life of the page. On an SSO-only domain that is the worst outcome available —
the password box then returns 403 and the button the user is told to use is not on the
screen. Discover is 10/min per IP and one person filling in the form costs up to four
calls, so a few colleagues behind one office address is enough. The comment above that
code already claimed to prevent exactly this; it only ever covered the 5xx case.
THE SSO-ONLY REFUSAL WAS AN ACCOUNT-EXISTENCE ORACLE
403 for an address that exists, 401 for one that does not — from an endpoint whose own
lockout returns 401 specifically to avoid that. The DOMAIN check now runs BEFORE the
account lookup, so both answer identically; whether a domain uses single sign-on is
already public through /sso/discover, so it reveals nothing new. The membership-level
refusal is deliberately downgraded to the generic 401, because a distinct answer there
would put the oracle back for exactly the accounts worth enumerating.
Verified: existing and invented addresses at an SSO-only domain both 403; and on an
instance with NO SSO configured, register/login/wrong-password/unknown-address are
201/200/401/401 — the hoisted check does not touch them.
BOOT PREFLIGHT
- a cold install ran `npm ci --omit=dev` unconditionally, so a first start on a
developer machine left `npm test` broken: same class of surprise as the prune this
file already warns about, through the other branch of the same if. Now production-
only.
- two servers starting together: the loser died with ENOTEMPTY even though the tree
was complete by then. It re-checks before failing.
- the opt-out accepted only '1', unlike every other boolean the server takes.
THE LIMITER FOLD, DONE PROPERLY
Unmatched paths under /api/organizations still minted a bucket each. My first fix was a
catch-all regex — which put every unknown path in ONE bucket WITH the real endpoints,
so flooding nonsense URLs exhausted the limit for /sso-only. That trades a bypass for a
denial of service. Folding is now by explicit shape: known endpoints keep their own
keys, everything else shares a bucket kept apart from all of them.
Verified: 120 unmatched paths give 60/60 (bypass closed), and after that flood
/sso-only, /sso and /sso/:id/test all still answer 401 rather than 429 (no starvation),
while 70 hits on one real endpoint do trip its own limit. The login trailing-slash
bypass stays closed.
1609 tests, three clean runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvjey4FNam49MN7ybjcq6A
179 lines
8.5 KiB
JavaScript
179 lines
8.5 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
* Make sure the dependencies this build needs are actually installed and loadable — BEFORE anything
|
|
* requires them.
|
|
*
|
|
* The normal upgrade path (scripts/upgrade.sh) runs `npm ci --omit=dev`, so this is not for the
|
|
* happy case. It is for the three ways a running box ends up with the wrong node_modules:
|
|
*
|
|
* ROLLBACK checking out an older tag to back out a bad release restores that tag's
|
|
* package.json but not its packages, so the server dies on a MODULE_NOT_FOUND for
|
|
* something the newer build had removed. That is a bad moment to be reading a
|
|
* stack trace: you are already rolling back because something else broke.
|
|
* NODE UPGRADE better-sqlite3 is a native module compiled against one ABI. Upgrading Node makes
|
|
* every boot fail with NODE_MODULE_VERSION mismatch, which reads like database
|
|
* corruption and is not.
|
|
* HAND EDITS a `git checkout`, a partly-copied tree, an interrupted install.
|
|
*
|
|
* All three present as a server that will not start, with an error that names a file rather than
|
|
* the action needed. Detecting and repairing is a few seconds; diagnosing is an outage.
|
|
*
|
|
* ⚠️ Deliberately dependency-free — only Node builtins. Anything it required could be the very
|
|
* thing that is missing.
|
|
*
|
|
* Set ST_SKIP_DEP_PREFLIGHT=1 to turn it off (air-gapped hosts, or an operator who manages
|
|
* node_modules themselves and does not want a boot reaching for the network).
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
|
|
const SERVER_DIR = path.join(__dirname, '..');
|
|
const NODE_MODULES = path.join(SERVER_DIR, 'node_modules');
|
|
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000; // a cold install on a Pi is genuinely slow
|
|
|
|
/** Which declared dependencies are not on disk. */
|
|
function missingDeps() {
|
|
let pkg;
|
|
try {
|
|
pkg = JSON.parse(fs.readFileSync(path.join(SERVER_DIR, 'package.json'), 'utf8'));
|
|
} catch {
|
|
return []; // no package.json is not our problem to diagnose
|
|
}
|
|
const declared = Object.keys(pkg.dependencies || {});
|
|
return declared.filter((name) => {
|
|
// A scoped or nested name is still one directory below node_modules.
|
|
try { return !fs.existsSync(path.join(NODE_MODULES, name, 'package.json')); } catch { return true; }
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Is the native module loadable by THIS Node?
|
|
*
|
|
* Checked by actually loading it, because the failure is an ABI mismatch that no version string
|
|
* comparison catches reliably — a rebuild against the same major can still differ.
|
|
*/
|
|
function nativeModuleBroken() {
|
|
try {
|
|
/*
|
|
* ⚠️ CONSTRUCT one, do not merely require it.
|
|
*
|
|
* better-sqlite3's entry point is plain JavaScript and loads the compiled `.node` binding
|
|
* lazily, so `require()` alone SUCCEEDS under a Node whose ABI the binary was not built for —
|
|
* the first version of this check did exactly that and reported a broken install as healthy,
|
|
* verified against a real Node 18 / Node 20 mismatch. Opening an in-memory database is what
|
|
* actually pulls the binding in, and it touches no file.
|
|
*/
|
|
const Database = require('better-sqlite3');
|
|
new Database(':memory:').close();
|
|
return null;
|
|
} catch (e) {
|
|
const msg = String((e && e.message) || '');
|
|
if (/NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|was compiled against a different/i.test(msg)) return msg;
|
|
if (/Cannot find module/i.test(msg)) return msg;
|
|
// Anything else is a real error in the module, not an installation problem — let it surface
|
|
// later with its own stack rather than being masked by an npm run.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function run(args, label) {
|
|
console.log(`[preflight] ${label}: npm ${args.join(' ')}`);
|
|
execFileSync('npm', args, { cwd: SERVER_DIR, stdio: 'inherit', timeout: INSTALL_TIMEOUT_MS });
|
|
}
|
|
|
|
function fail(reason, hint) {
|
|
console.error(`[preflight] ${reason}`);
|
|
console.error(`[preflight] ${hint}`);
|
|
console.error('[preflight] Set ST_SKIP_DEP_PREFLIGHT=1 to boot without this check.');
|
|
process.exit(1);
|
|
}
|
|
|
|
function preflight() {
|
|
// Same spellings as every other boolean the server accepts, so an operator who writes `true`
|
|
// does not silently get a boot that reaches for the registry anyway.
|
|
if (['1', 'true', 'yes'].includes(String(process.env.ST_SKIP_DEP_PREFLIGHT || '').toLowerCase())) return;
|
|
|
|
const missing = missingDeps();
|
|
const nodeModulesAbsent = !fs.existsSync(NODE_MODULES);
|
|
|
|
if (missing.length || nodeModulesAbsent) {
|
|
const what = nodeModulesAbsent
|
|
? 'node_modules is missing'
|
|
: `${missing.length} dependency/dependencies missing: ${missing.slice(0, 6).join(', ')}${missing.length > 6 ? '…' : ''}`;
|
|
console.warn(`[preflight] ${what} — installing before start.`);
|
|
try {
|
|
/*
|
|
* `npm ci` when there is a lockfile and nothing installed: it is reproducible and it is what
|
|
* upgrade.sh uses. Otherwise `npm install`, because `ci` DELETES node_modules first and would
|
|
* throw away a working tree to fix one missing package.
|
|
*/
|
|
const hasLock = fs.existsSync(path.join(SERVER_DIR, 'package-lock.json'));
|
|
if (hasLock && nodeModulesAbsent) {
|
|
/*
|
|
* Nothing installed, so `ci` has nothing to destroy and gives a reproducible tree.
|
|
*
|
|
* `--omit=dev` ONLY when this is plainly a production boot. Applying it unconditionally
|
|
* meant a cold start on a developer machine installed 307 packages and left `npm test`
|
|
* broken — js-yaml, puppeteer-core and socket.io-client absent — which is the same class of
|
|
* surprise as the prune this file already warns about, arriving through the other branch of
|
|
* the same `if`.
|
|
*/
|
|
const prod = process.env.NODE_ENV === 'production';
|
|
run(prod ? ['ci', '--omit=dev', '--no-audit', '--no-fund'] : ['ci', '--no-audit', '--no-fund'], 'installing');
|
|
} else {
|
|
/*
|
|
* ⚠️ Install ONLY what is missing, by name, and never `--omit=dev` on a populated tree.
|
|
*
|
|
* `npm install --omit=dev` reconciles the whole tree, which PRUNES devDependencies — so
|
|
* merely starting the server deleted socket.io-client, puppeteer-core and js-yaml, and broke
|
|
* `npm test`. A review watched it happen. A boot-time repair that quietly removes packages
|
|
* is worse than the failure it fixes, so this touches nothing it was not asked to.
|
|
*
|
|
* `--no-save` because a server starting up has no business editing package.json.
|
|
*/
|
|
run(['install', '--no-save', '--no-audit', '--no-fund', ...missing], 'installing missing packages');
|
|
}
|
|
} catch (e) {
|
|
/*
|
|
* An install can fail because ANOTHER server started at the same moment and won the race —
|
|
* observed as `ENOTEMPTY … rename node_modules/fs-extra`. The tree is complete by the time we
|
|
* see the error, so exiting here killed a process that had nothing wrong with it. Re-check
|
|
* before giving up; only a genuinely incomplete tree is fatal.
|
|
*/
|
|
const afterFailure = missingDeps();
|
|
if (afterFailure.length) {
|
|
fail(`could not install dependencies: ${e && e.message}`,
|
|
'Run `npm ci --omit=dev` in the server directory, or check network access to the npm registry.');
|
|
}
|
|
console.warn(`[preflight] install reported an error but the tree is complete (${e && e.message}) — continuing.`);
|
|
}
|
|
const still = missingDeps();
|
|
if (still.length) {
|
|
fail(`still missing after install: ${still.join(', ')}`, 'Check the npm output above.');
|
|
}
|
|
console.log('[preflight] dependencies installed.');
|
|
}
|
|
|
|
const nativeProblem = nativeModuleBroken();
|
|
if (nativeProblem) {
|
|
console.warn(`[preflight] better-sqlite3 will not load under Node ${process.version} — rebuilding.`);
|
|
console.warn(`[preflight] ${nativeProblem.split('\n')[0]}`);
|
|
try {
|
|
run(['rebuild', 'better-sqlite3'], 'rebuilding native module');
|
|
} catch (e) {
|
|
fail(`could not rebuild better-sqlite3: ${e && e.message}`,
|
|
`Run \`npm rebuild better-sqlite3\` in the server directory. This usually means Node changed version (now ${process.version}) and the module needs recompiling; a build toolchain (python3, make, g++) must be present.`);
|
|
}
|
|
if (nativeModuleBroken()) {
|
|
fail('better-sqlite3 still will not load after a rebuild.',
|
|
'Delete server/node_modules and run `npm ci --omit=dev`.');
|
|
}
|
|
console.log('[preflight] native module rebuilt.');
|
|
}
|
|
}
|
|
|
|
module.exports = { preflight, missingDeps, nativeModuleBroken };
|