Skip to content

Allow local overrides of the dev server config - #201

Open
djscruggs wants to merge 3 commits into
mainfrom
feature/local-dev-config-override
Open

Allow local overrides of the dev server config#201
djscruggs wants to merge 3 commits into
mainfrom
feature/local-dev-config-override

Conversation

@djscruggs

@djscruggs djscruggs commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Testing the mediator from a phone means serving it under a tunnel hostname,
which requires changing server.host and server.baseUri — values in a
tracked config that differ per machine. Editing configs/dev.js directly
risks committing them or losing them in a rebase.

configs/dev.js now imports configs/local.js last, when present, so those
overrides win. The file is gitignored. Absent it, behavior is unchanged.

Using it

cp configs/local.js.example configs/local.js

Then replace the placeholder hostname and restart. Delete the file to go back
to the defaults.

configs/local.js.example is committed as a copyable starting point covering
the case the override exists for. It carries the two things that are easy to
get wrong:

  • Set server.host and server.baseUri, not server.domain. domain
    also drives server.bindAddr, and host appends server.port unless the
    port is 443 — so overriding the port to 443 to get a clean hostname stops
    the server listening on 33443 locally.
  • Point the tunnel at the HTTPS port (33443), not the HTTP one. The HTTP
    port redirects unconditionally to https://<server.host>; with a tunnel
    hostname that redirects to itself forever.

Why not bedrock's --config flag?

bedrock.start() already imports every --config path, after the static
config imports and before bedrock.configure, so
node authn.localhost.js --config ./configs/local.js would get the same
result with no change to configs/dev.js.

Auto-loading is chosen because it applies without being remembered. Every
existing script, README line, and habit says plain npm start; an override
that needs a flag is one a developer will lose on the next terminal. Making
the file's presence the switch means the override survives contact with
muscle memory, and deleting the file is the way back.

@bedrock/config-yaml, imported from lib/index.js, is a third nominal
channel. It reads /etc/bedrock-config/app.yaml or a base64 blob in
BEDROCK_CONFIG, which suits deployment rather than a working copy, so
configs/dev.js now names local.js as the development channel to save the
next reader the comparison.

Also

Corrects the README's Configuration section, which pointed at
./configs/authn.localhost.js. That file is authn.localhost.js at the
repository root; the tunable config is configs/dev.js.

Corrects the README's stated Node and npm versions, which read v16+ and v8+
against an engines.node of >=24.

Why

Split out from #200, which needed this to test on a real device, so it can be
reviewed on its own.

Testing the mediator from a phone means serving it under a tunnel
hostname, which requires changing `server.host` and `server.baseUri` --
values in a tracked config that differ per machine.

`configs/dev.js` now imports `configs/local.js` last, when present, so
those overrides win. The file is gitignored; absent it, behavior is
unchanged.
The override is only discoverable by reading `configs/dev.js`, and anyone
using it has to derive the file's shape and the bedrock config keys
involved from scratch.

`configs/local.js.example` is a copyable starting point covering the case
the override exists for: serving the mediator under a tunnel hostname so a
phone can reach it. It carries the reasoning that is easy to get wrong --
why `server.host` and `server.baseUri` are set rather than `server.domain`,
and why the tunnel must point at the HTTPS port rather than the HTTP one,
which would redirect to itself forever. The hostname is a placeholder.

Also corrects the Configuration section, which pointed at
`./configs/authn.localhost.js`. That file is `authn.localhost.js` at the
repository root; the tunable config is `configs/dev.js`.
@applesnort

Copy link
Copy Markdown
Contributor

Mechanism works — confirmed the load ordering (top-level await in configs/dev.js does suspend the importer, so local.js is fully evaluated before bedrock.start()) and that assignment overrides a computed config value. One real bug, two factual errors in the new prose. Nothing blocking.

configs/dev.js:33 — filesystem path used as an ESM specifier

await import(localConfigPath) passes an absolute path where import() expects a URL. With the checkout under a directory containing #, existsSync returns true and the next line throws, naming a path nobody asked for:

exists: true /.../authn.io#2/configs/local.js
ERR_MODULE_NOT_FOUND - Cannot find module '/.../authn.io'

On Windows it fails unconditionally — C:\... parses as a c: URL scheme (ERR_UNSUPPORTED_ESM_URL_SCHEME). One word, from the node:url import already on line 11:

import {fileURLToPath, pathToFileURL} from 'node:url';
...
await import(pathToFileURL(localConfigPath).href);

configs/local.js.example — the bindAddr justification isn't true here

Set these two derived values rather than server.domain: that also drives server.bindAddr

configs/dev.js:24 already hard-assigns config.server.bindAddr = ['0.0.0.0'], and that assignment replaces the computed value permanently, so setting server.domain in local.js can't touch it:

bindAddr after dev.js:         [ '0.0.0.0' ]
bindAddr after setting domain: [ '0.0.0.0' ]
host after setting domain:     YOUR-HOSTNAME.ngrok.app:33443

The conclusion still holds for the other reason given — server.host is domain + ':' + port, so overriding domain gets you a tunnel hostname with a local port glued on. Suggest dropping the bindAddr clause and keeping the port one.

configs/dev.js:28 — the comment recommends the knob the example warns against

The comment offers "e.g. pointing server.domain at a tunnel hostname"; the example file and the PR description both say specifically to use server.host instead. configs/dev.js is the file a reader opens first and it points at the wrong one.

Three smaller things in the example

baseUri is derived, so setting it is redundant. cc('server.baseUri', 'https://${server.host}') is a lazily-evaluated template — overriding host alone already yields https://YOUR-HOSTNAME.ngrok.app. Nothing in lib/, web/, or any @bedrock/* package reads server.baseUri; only @bedrock/server/lib/config.js defines it. As written the example ships two values that must agree, one of which would have followed the other, so someone who later edits host and forgets baseUri gets exactly the mismatch the prose warns about.

Overriding server.host disables the localhost CORS patch. @bedrock/express/lib/index.js:167 gates Access-Control-Allow-Private-Network on config.server.host.includes('localhost'), so with a tunnel hostname that header stops being sent. Harmless for traffic arriving through the tunnel, but it qualifies "so https://authn.localhost:33443 keeps working alongside the tunnel" — the port still listens, but a public-origin coordinator site hitting the local mediator directly now fails Chrome's private-network preflight while local.js is in place. Worth a sentence; the symptom is "worked yesterday, broken today, git status clean".

local.js can't register bedrock event handlers. It's imported after lib/index.js has imported @bedrock/config-yaml, so a bedrock.events.on('bedrock.configure', ...) in it trips Error: "bedrock-config-yaml" must be the last import. Plain assignment is fine, and this would bite --config identically, but one line ("assign config values only, don't register event handlers here") pre-empts a baffling message.

Why not bedrock's --config?

Not a request to change the approach — the description should just answer it, because bedrock.start() already does this. @bedrock/core/lib/index.js:279:

for(const cfg of program.opts().config) {
  await import(path.resolve(process.cwd(), cfg));
}

Repeatable CLI flag, called at line 140 — after every static config import, before the primary/worker split and before bedrock.configure. Verified against the real server that node authn.localhost.js --config ./configs/local.js overrides host and leaves port and bindAddr intact. So the .gitignore line, the .example file, the README note and a "start:local" script get there with no change to configs/dev.js — and the bug above disappears with it.

The tradeoff favours what's here, though: --config only applies when you remember the flag, whereas auto-loading means plain node authn.localhost.js and every existing script and doc picks the override up. For a dev-only file that's worth the ten lines. Just say so, since the next reader who knows the flag will ask.

Also worth one line in the configs/dev.js comment: @bedrock/config-yaml is imported at lib/index.js:18, so there are now three nominal override channels with no stated precedence. It's a poor fit here (default path /etc/bedrock-config/app.yaml, or a base64 blob in BEDROCK_CONFIG), so not a real contender — but saying local.js is the dev channel would save someone a session.

Aside, not this PR

configs/dev.js:24 config.server.bindAddr = ['0.0.0.0'] (from 928c0a4, already on main) means every npm start exposes the dev mediator on every interface, including café and guest wifi — the comment documents the exposure but nothing enforces the caveat. This PR creates precisely the gitignored channel that setting belongs in, so the two seem worth resolving together: move it into local.js.example next to the tunnel settings and return the tracked default to loopback.

Separately, README:48 says "Node.js v16+ / npm v8+" while engines.node is >=24. Pre-existing, but this PR is already editing that file.

Import by file URL, not filesystem path. `import()` takes a URL, so a bare
absolute path truncates at a `#` in any parent directory name -- reporting a
module nobody asked for -- and on Windows parses the drive letter as a URL
scheme. Reproduced both with a `#` in the path; `pathToFileURL` fixes it.

Drop `server.baseUri` from the example. It is the lazily-evaluated template
`https://${server.host}`, so it already follows `host`; setting both ships two
values that must agree, which is the mismatch the surrounding prose warns
about. Verified by removing it: both the tunnel and local origins still serve.

Correct the `bindAddr` justification. `configs/dev.js` hard-assigns
`config.server.bindAddr`, so setting `server.domain` cannot affect it -- the
reason to prefer `server.host` is that `host` appends the port.

Point `configs/dev.js` at `server.host` rather than `server.domain`. That
comment is the first thing a reader opens and it recommended the knob the
example warns against.

Note that the file cannot register bedrock event handlers, since it is
imported after `@bedrock/config-yaml` and would trip that package's
last-import assertion with a message that does not explain itself.

Note that overriding `server.host` stops `@bedrock/express` sending
`Access-Control-Allow-Private-Network`, which it gates on `host` containing
`localhost`. Traffic through the tunnel is unaffected, but a public-origin
coordinator reaching the local mediator directly will start failing Chrome's
private-network preflight while the file is in place.

Name `local.js` as the development override channel, since
`@bedrock/config-yaml` is a third nominal channel with no stated precedence.

Show restricting `server.bindAddr` to loopback in the example. The all-
interfaces default exists for Docker and is documented as such, but nothing
offers the alternative at the point of use.

Correct the README's Node and npm versions, which read v16+/v8+ against an
`engines.node` of `>=24`.
@djscruggs

Copy link
Copy Markdown
Contributor Author

Thanks — all of it applied in 0b7e2cd. Verified each point rather than taking it on trust; every one held.

The import() bug. Real, and worse than the # case alone: a bare absolute path fails there, and pathToFileURL fixes it. Reproduced with a # in a parent directory name — bare path gives ERR_MODULE_NOT_FOUND, pathToFileURL loads. Fixed, using the existing node:url import.

baseUri is redundant. Confirmed nothing outside @bedrock/server/lib/config.js reads it, and it is the lazy template https://${server.host}. Removed from the example and from my own local.js, then restarted: both the tunnel origin and https://authn.localhost:33443 still serve 200, so host alone does carry it. You are right that shipping both invited exactly the mismatch the prose warned about.

The bindAddr claim was wrong. configs/dev.js hard-assigns it, so server.domain cannot reach it. Dropped that clause; the port-appending reason is the real one and is what the example now gives.

configs/dev.js pointed at the wrong knob. Fixed to server.host, and it now cross-references the example.

The three smaller points are all in, each as a sentence where it applies: no event handlers (with the reason, since that error message explains nothing), the Access-Control-Allow-Private-Network side effect and its "worked yesterday, git status clean" shape, and local.js named as the dev channel with a note on why config-yaml is not a contender.

On --config. Good question, and I had not considered it. Your own reasoning is the reason to keep the auto-load: an override that applies only when you remember a flag will be forgotten, and every existing script and doc says plain npm start. I would rather the file's presence be the switch. Added to the PR description so the next reader who knows the flag gets an answer.

The asides. README Node/npm versions corrected to v24+/v10+ against engines.node: '>=24', since this PR already edits that file.

bindAddr I did not move. Relocating it would change what plain npm start does for everyone relying on the Docker path, and it is on main rather than this branch, so it wants its own change with that call made deliberately. The example now shows restricting it to loopback, which gives the shared-network case an answer at the point of use without changing the default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants