Skip to content

fix(config): boot the single-host bundle on loopback origins - #333

Open
Lob26 wants to merge 4 commits into
theam:mainfrom
Lob26:fix/bundle-loopback-preview-origin
Open

Lob26 wants to merge 4 commits into
theam:mainfrom
Lob26:fix/bundle-loopback-preview-origin

Conversation

@Lob26

@Lob26 Lob26 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

What changes

docker compose up starts the bundle. Today it does not: the API exits during
config validation before it ever listens.

The api container runs the api image, which sets NODE_ENV=production
(Dockerfile:86), and docker-compose.yml defaults FACILITY_PREVIEW_URL to
http://preview.localhost:4400. Production validation refuses any preview
origin that is not HTTPS:

FACILITY_PREVIEW_URL must use HTTPS in production

So the command in the Compose file's own header comment —
SECRET_MASTER_KEY=$(openssl rand -base64 32) docker compose up -d — cannot
bring up an instance.

An instance whose every origin is loopback HTTP is now exempt from that
requirement. This is the carve-out the interactive OAuth block in the same
superRefine already makes for its own URL set, applied to the preview origin
that did not have it. isLoopbackHostname additionally accepts names under
.localhost, which RFC 6761 §6.3 reserves for the loopback interface —
Facility needs more than the bare name because the preview origin must stay a
registered site of its own.

A second commit adds self-host/bundle.md, the page that describes the path.

Why

Requiring HTTPS on loopback refuses a configuration that has no transport to
protect: there is no name to obtain a certificate for and nothing is reachable
off the machine. Any origin that leaves the machine puts the whole set back
under the requirement, and the separate-registered-site rule is untouched —
previews stay isolated from the control plane on loopback too.

Blast radius, stated rather than buried. isLoopbackHostname has three call
sites. Two widen: FACILITY_INSECURE_DEV may now be enabled on a .localhost
control origin, and MCP_PUBLIC_URL may be HTTP there. Both already accepted
localhost itself, neither leaves the host, and FACILITY_INSECURE_DEV remains
refused under NODE_ENV=production regardless.

docker-compose.yml has carried the whole 0.12 control plane since #289, but no
page describes it: the quickstart is the from-source path and production.md
mentions the file in one clause. The new page is the single documentation page
#19 asks for, and it states the limits rather than leaving them to be
discovered — GitHub cannot deliver webhooks to localhost, the bundle is
TLS-free only while every origin stays loopback, and Facility never reclaims
workspace volumes by age.

Related: #183. Its root cause — a shipped template value the platform's own
validation rejects — was fixed in #159 by the OptionalNonEmpty preprocess.
This is the same failure class one layer up, and the new test pins the .env.example
half so it cannot come back. #183 looks closeable.

Verification

The regression fails without the fix, for the right reason:

× boots the single-host bundle on loopback origins
AssertionError: [ { "path": ["FACILITY_PREVIEW_URL"],
  "message": "FACILITY_PREVIEW_URL must use HTTPS in production" } ]

Coverage pins the shipped defaults rather than a paraphrase of them: the Compose
environment boots; a published control plane with a loopback preview does not; a
loopback control plane with a published preview does not; the bare control
hostname is still refused as a preview origin; and .env.example is parsed as
dotenv delivers it and asserted to boot with only the master key filled in.

vitest run test/config.test.ts                          20 passed
vitest run config + workspace-preview + identity-provider + oauth
                                                        72 passed | 1 skipped
tsc --noEmit (@facility/api)                            clean
node guards/run.mjs                                     2 guards, 0 failed
pnpm --filter @facility/docs build                      [SUCCESS]
  • pnpm verify passes locally
  • Behaviour verified beyond the test suite (say how)
  • Documentation updated, or no user-facing change

pnpm verify does not pass on this machine and not because of this change:
test:dev reports 116 tests, 92 pass, 24 fail here, and I measured the
identical 92/24 on a clean main. The failures are Windows environment noise —
the patched image-size cases, tar failing to resolve C:, and the registry
publication tests. Beyond the suite, the defect and the fix were both confirmed
against the real file: docker compose --env-file .env.example config reproduces
the interpolation path, and the failing validation was reproduced with the exact
environment the Compose file hands the container.

🤖 Claude Code helped

The Compose bundle cannot start. Its api container runs the `api` image,
which sets NODE_ENV=production, and docker-compose.yml defaults
FACILITY_PREVIEW_URL to http://preview.localhost:4400. Production
validation refuses any preview origin that is not HTTPS, so readConfig
throws before the API listens:

    FACILITY_PREVIEW_URL must use HTTPS in production

The command in the Compose file's own header comment therefore never
brings up an instance.

Requiring HTTPS on loopback refuses a configuration that has no transport
to protect: there is no name to obtain a certificate for and nothing is
reachable off the machine. Exempt an instance whose every origin --
preview included -- is loopback HTTP, the same carve-out the interactive
OAuth block already makes for its own URL set. Any origin that leaves the
machine puts the whole set back under the HTTPS requirement, and the
separate-registered-site rule is untouched: previews stay isolated from
the control plane on loopback too.

isLoopbackHostname now accepts names under `.localhost`, which RFC 6761
section 6.3 reserves for the loopback interface. Facility needs more than
the bare name because the preview origin must be a registered site of its
own. This widens two other call sites: FACILITY_INSECURE_DEV may be
enabled on a `.localhost` control origin, and MCP_PUBLIC_URL may be HTTP
there. Both already accepted `localhost` itself, and neither leaves the
host.

Regression coverage pins the shipped defaults rather than a paraphrase of
them: the Compose environment boots, a published control plane with a
loopback preview does not, a loopback control plane with a published
preview does not, and the bare control hostname is still refused as a
preview origin. The shipped .env.example is also parsed as dotenv
delivers it and asserted to boot with only the master key filled in.
docker-compose.yml has carried the whole 0.12 control plane since theam#289,
but no page describes it: the quickstart is the from-source path
(corepack, pnpm install, pnpm dev) and production mentions the file in
one clause. Someone evaluating Facility with Docker has nothing to
follow.

The new page is the single documentation page issue theam#19 asks for. It
covers what the bundle is and is not, the one-shot migrate and
runner-image services and why they read as exited, the surface table
including the separate preview origin, the two GitHub applications --
OAuth App for sign-in, GitHub App for repository automation -- and
binding the first owner with the operator CLI that already ships inside
the api image.

It also states the limits rather than leaving them to be discovered:
GitHub cannot deliver webhooks to localhost, so UI and MCP triggers work
untunnelled and repository triggers do not; the bundle runs TLS-free only
while every origin stays loopback; and Facility never reclaims workspace
volumes by age.

@adrian-lorenzo adrian-lorenzo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks.

Please bind the Compose API and web ports to loopback before allowing HTTP. Localhost URLs do not restrict network access. Add a regression check for those bindings.

The HTTP carve-out this PR adds rests on every origin being loopback, and
that premise was false. `ports: ["4400:4400"]` and `["3400:3400"]` publish
on every interface, so a `localhost` URL constrained nothing: the bundle
served control-plane sessions and preview traffic to the whole network
segment in plaintext, and the PR description claimed the opposite.

Both host ports are now bound to `127.0.0.1`. Container-to-container
traffic uses the Compose network, so nothing the stack needs internally
was published in the first place. Reaching the instance from another
machine now requires changing the binding, which is the moment to put TLS
in front of it rather than a property to discover afterwards.

The regression lives beside the rule that depends on it, in
`config.test.ts`: the premise and the carve-out were in different files,
which is how the premise rotted unnoticed. It parses the shipped Compose
file, fails on any published port that is not loopback-bound, and asserts
which services publish at all so it cannot pass vacuously.
@Lob26

Lob26 commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at aa4bf30. You are right, and worse than you put it: my description claimed nothing was reachable off the machine, while 4400:4400 published plaintext sessions and preview traffic to the whole network segment. Both ports are now bound to 127.0.0.1; container traffic uses the Compose network, so nothing internal was published anyway. The regression lives in config.test.ts beside the carve-out, because the premise and the rule sitting in different files is how the premise rotted unnoticed. Thank you for not letting that through.

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