Skip to content

fix(config): never let a bad config.json brick startup - #34

Closed
SanikaA3 wants to merge 1 commit into
haxtheweb:mainfrom
SanikaA3:fix/2967-safeguard-config-loading
Closed

fix(config): never let a bad config.json brick startup#34
SanikaA3 wants to merge 1 commit into
haxtheweb:mainfrom
SanikaA3:fix/2967-safeguard-config-loading

Conversation

@SanikaA3

@SanikaA3 SanikaA3 commented Sep 8, 2026

Copy link
Copy Markdown

Refs #2967. This is the haxcms-nodejs half of that issue — see Scope at the bottom.

Problem

HAXCMS is exported as a module-level singleton, so its constructor runs at require time. The config load had no guards on either of its two throwing calls:

// self healing if config is missing
if (!fs.existsSync(path.join(this.configDirectory, "config.json"))) {
  fs.copyFileSync(boilerplate, path.join(this.configDirectory, 'config.json'));
}
this.config = JSON.parse(fs.readFileSync(path.join(this.configDirectory, "config.json"), ...));
  • JSON.parse throws SyntaxError on a present-but-invalid config.json
  • copyFileSync throws EACCES when the config directory is read-only

Either escapes the constructor at require time, so the process does not start at all. The existing comment says "self healing if config is missing" and that part works — the gap is a file that exists but cannot be parsed.

Reproduced against main with a temp _config directory:

=== case: valid config ===    LOADED ok
=== case: corrupt config ===  CRASH: SyntaxError: Unexpected end of JSON input
=== case: empty config ===    CRASH: SyntaxError: Unexpected end of JSON input

Note that an empty file fails identically to a corrupt one — which is what a truncated write or a full disk leaves behind.

Fix

Extracts the load into src/lib/loadSystemConfig.js, alongside the existing discoverConfigPath.js. It separates the three failure modes, because they want different responses:

mode response
missing restore boilerplate onto disk (best effort — a read-only directory now falls through instead of throwing)
empty fall back in memory
corrupt fall back in memory, and leave the file alone

The call site becomes one line, and the returned config is never null, so downstream property access in the constructor cannot fatal. Every failure mode logs its real cause, including the underlying parse or errno message.

Two decisions worth calling out

A corrupt file is never overwritten. The obvious fix would be to re-copy the boilerplate over bad JSON. This deliberately does not: in HAXiam that path is a symlink to a shared _iamConfig/config.json that may hold API keys and theme customizations, and replacing it to fix a stray comma would destroy them. The site stays up on in-memory defaults and the log tells the admin what to repair. There is a test asserting the file is byte-for-byte unchanged after a failed load.

The in-memory fallback drops deploymentProfile. The shipped boilerplate sets "deploymentProfile": "self-hosted-multi-site". Carrying that into a degraded boot would stamp a multi-site profile onto a single-site or IAM install, enabling MCP and relaxing IAM tenant validation — the same hazard the issue raises for the PHP loader. Dropping the key lets the constructor's existing context-aware logic derive the profile instead. The last-resort minimal config likewise ships mcp: { enabled: false, readOnly: true }, so a degraded boot never opens more surface than a healthy one.

Testing

The existing unit suite was executed and passes — no regressions:

main this branch
npm run test:unit 939 passed, 0 failed 950 passed, 0 failed

The 11 additional tests are test/unit/load-system-config.test.cjs, covering: valid load (and that a healthy load logs nothing), missing-file restore, corrupt, empty, non-object JSON, unwritable config directory, unusable boilerplate, the deploymentProfile drop, mcp closed in the minimal config, that a corrupt file is left untouched, and that the returned config is never null or non-object across a range of malformed inputs.

Behaviour after the fix, same reproduction as above:

=== case: corrupt config ===
HAXcms: config.json could not be parsed (Unexpected end of JSON input). Continuing with
in-memory defaults; the file was left untouched so any hand edits are preserved.
  -> boots

=== case: empty config ===
HAXcms: config.json is empty. Continuing with in-memory defaults; delete the file to have
it restored from boilerplate.
  -> boots

A valid config is unchanged and logs nothing.

Scope

#2967 is only half addressed by this PR. The issue names haxcms-php as the primary target (IAM runs the PHP core) and describes safeguards 1, 2 and 3 for system/backend/php/lib/HAXCMS.php, with haxcms-nodejs getting parity for the corrupt case. This PR is the nodejs parity half only.

I left the PHP side alone rather than guess at it: I could not run PHP to verify the change, and an unverified constructor restructure in a security-adjacent path seemed worse than none. The issue splits cleanly by codebase, so this stands on its own — but #2967 should stay open for the PHP loader.

Happy to take feedback or adjust anything here.

🤖 Generated with Claude Code

HAXCMS is a module-level singleton, so its constructor runs at require
time. The config load had no guards:

    if (!fs.existsSync(path.join(this.configDirectory, "config.json"))) {
      fs.copyFileSync(boilerplate, ...);
    }
    this.config = JSON.parse(fs.readFileSync(...));

Two unguarded calls. A present-but-invalid config.json throws SyntaxError
out of JSON.parse, and a read-only config directory throws EACCES out of
copyFileSync. Either one escapes the constructor at require time, so the
process cannot start at all.

Reproduced against main with a temp _config holding a corrupt and then an
empty config.json - both give:

    CRASH: SyntaxError: Unexpected end of JSON input

Extracts the load into src/lib/loadSystemConfig.js, which separates the
three failure modes because they need different responses:

  missing    restore boilerplate onto disk (best effort; a read-only
             directory now falls through instead of throwing)
  empty      fall back in memory
  corrupt    fall back in memory and LEAVE THE FILE ALONE

Not overwriting a corrupt-but-present file is deliberate. It may be a
symlink to a shared config carrying hand edits (API keys, themes);
replacing it with boilerplate would destroy them. The log tells the admin
what to fix so they keep their customizations.

The returned config is never null, so downstream property access in the
constructor cannot fatal. Each failure mode logs its real cause,
including the underlying parse or errno message.

One subtlety: the shipped boilerplate sets
deploymentProfile: self-hosted-multi-site. Carrying that into a degraded
boot would enable MCP and relax IAM tenant validation on a single-site or
IAM deployment, so the in-memory fallback drops the key and lets the
existing constructor logic derive the profile from the real operating
context. The minimal last-resort config likewise ships mcp closed
(enabled: false, readOnly: true) so a degraded boot never opens more
surface than a healthy one.

Verification:
  - unit suite 939/939 on main, 950/950 here (11 new)
  - corrupt and empty configs now boot with a diagnostic instead of
    crashing; valid configs are unchanged and log nothing
  - a corrupt file is byte-for-byte identical after load
  - unwritable config directory returns a usable config and logs

Scope: this is the haxcms-nodejs half of #2967. The PHP loader in
haxcms-php is the issue's primary target and is not touched here.

Refs #2967

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SanikaA3
SanikaA3 requested a review from btopro as a code owner September 8, 2026 19:05
@codesandbox

codesandbox Bot commented Sep 8, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

@btopro btopro closed this Sep 8, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants