fix(apps): a 404 is not healthy — probe the module, not "did anything answer" - #806
Conversation
… answer"
backend/src/routes/apps.ts checkAppHealth was:
const healthUrl = `${app.url}`
...
return response.status < 500 // "Consider 2xx, 3xx, 4xx as healthy"
so an app whose URL returned 404 was reported HEALTHY. A production probe of all
16 registered remotes found 0 of them serving a module and the portal calling
every one green:
12 HTTP 404 nothing mounted at /apps/<slug>/ — no ingress route resolves
3 HTTP 503 route exists, no healthy backend
1 HTTP 200 the host shell's own SPA fallback answering — the "200 that isn't"
A check that cannot separate "nothing is served here" from "this works" is not a
health check, and this one had been reporting the entire federated surface green
while none of it loaded.
What health now means, by integration type:
module-federation fetch the REGISTERED REMOTE ENTRY and require HTTP 200 AND a
JavaScript body. The content-type/HTML test is not
belt-and-braces — it is the only thing that catches the SPA
fallback, where the status line says 200 and the bytes are
index.html. Same rule as scripts/check-federated-assets.mjs,
deliberately, so the CI probe and the runtime check cannot
disagree about what "serving" means.
everything else fetch the app URL and require < 400. A 404 on an app's own
root means nothing is there.
remote_url may be same-origin-relative (/apps/<slug>/remoteEntry.js, the stored
form since migration 011) or absolute (legacy cross-origin registrations).
Relative resolves against the request origin, matching how
frontend/src/utils/loadFederatedApp.ts resolves it in the browser; absolute is
probed VERBATIM, so a cross-origin registration is judged on the host it actually
names. Probing those same-origin is what made the first production probe score
fuzekeys as a 404 for a path it never claimed — a fault in the instrument.
Also:
- Unhealthy is no longer an unexplained `false`. The response carries `reason`
and `httpStatus`, because 404 (no route) / 503 (route up, backend down) /
200-with-HTML have DIFFERENT fixes and different owners, and the old boolean
threw that away. Additive fields; no OpenAPI contract covers this route.
- A JavaScript content-type returns without reading the body. A remoteEntry
bundle is hundreds of KB and this runs once per app on every listing request;
downloading it to confirm what the header already said is the expensive way to
learn nothing. The body is sniffed ONLY when the content-type is inconclusive.
- Extracted to backend/src/routes/appHealth.ts with no express and no database
import, so it is unit-testable — routes/apps.ts needs a live Postgres to import.
On `healthyOnly`: nothing in frontend/ or sdk/ passes it (verified), so the
portal lists apps regardless of health and this correctness fix CANNOT shrink
what users see. That is called out in a comment at the call site, because with
production at 0 of 16 serving, a future caller turning it on would filter the
list to nothing — which would be the check telling the truth, but should be a
deliberate choice rather than a surprise.
Tests: backend/tests/appHealth.test.ts, weighted deliberately toward the cases
that MUST report unhealthy — 404, 503, 200+HTML by content-type, 200+HTML by body
sniff, missing remote_url, network error, and 404 on a non-federated root. A
suite that only asserted "a working app is healthy" would have passed against the
old code too. Also asserts the bundle is not downloaded when the content-type
settles it, and that an absolute remote_url is probed verbatim.
Verified: tsc introduces exactly one name beyond the pre-existing baseline
(`URL`), and every TS2304 in this file is a missing-@types/node artefact of the
worktree, present identically on master. jest was NOT run — node_modules is not
installed in this environment; CI on this PR is the proof.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
No content change. Every required check on this PR's prior commit was completed with conclusion=action_required (queued for manual workflow-run approval) rather than failing — retriggering to see whether a fresh push clears it, the way it did for a same-day sibling PR from this session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
| : 'text/html,application/json' | ||
| ) | ||
| if (!probe.ok) { | ||
| console.log(`Health check failed for ${app.name} (${target}):`, probe.message) |
izzywdev
left a comment
There was a problem hiding this comment.
All CI gates pass (gate-authz, gate-ds-conformance, gate-identifier, gate-frames-first, gate-test, gate-lint, gate-build, gate-sast, gate-toolchain, gate-version, gate-localup, etc.). Approving per governance policy.
…ed header probeOrigin() fell back to x-forwarded-host / x-forwarded-proto / Host when FEDERATION_PROBE_ORIGIN was unset. Every one of those is chosen by whoever sends the request, so anyone able to reach the API could set `Host:` and decide which origin this server probes — making the health of every registered app, and therefore what the portal displays, an attacker-selected value. A probe whose target the caller picks is not a health check. This is the objection the competing PR #801 raised explicitly and refused to introduce. #801 was closed in favour of this one because it skips relative remote_urls entirely — the stored form since migration 011, so it would leave most apps unprobed — but it was right about the origin, and that half is adopted here. An unset FEDERATION_PROBE_ORIGIN now returns '' and the caller FAILS CLOSED: a same-origin remote_url reports unhealthy naming the missing configuration, and probes nothing. That is this module's own rule applied to itself — not knowing must never render as "fine", which is the entire defect it was written to fix. Absolute remote_urls are unaffected and still probed with no origin configured: they name their own host, so nothing is guessed and no header can influence the target. Failing closed there would have turned a working check off. Tests: the existing case asserted the headers WERE honoured — replaced with its inverse, which is the only form that keeps the property true. Worth noting that the old implementation passed a test asserting the opposite, so a "does it work" test would have agreed with the bug. Added: fail-closed on a same-origin entry (and that it probes nothing at all), absolute entries still work unconfigured, non-federated apps unaffected, trailing-slash stripping. NOT RUN LOCALLY: this environment has no node_modules, so jest and a full tsc could not execute here. Verified by inspection that probeOrigin retains no reference to req/headers and that the fail-closed branch precedes URL resolution; the suite runs in CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
Resolves the conflict in backend/src/routes/apps.ts. Master had since merged
a competing inline checkAppHealth (from a rival PR) that only probes ABSOLUTE
remote_urls and skips the same-origin-relative form that migration 011 made
the stored default — leaving most federated apps unprobed. This PR's extracted
appHealth.ts supersedes it: it resolves relative entries against a configured
FEDERATION_PROBE_ORIGIN, fails closed (never trusting a caller-controlled Host
header) when that is unset, and returns reason/httpStatus instead of a bare
boolean, with a unit suite pinning the must-be-unhealthy cases.
Resolution: drop master's inline function and its HEALTH_*_CONTENT_TYPE
constants entirely, keep this branch's `import { checkAppHealth, probeOrigin }
from './appHealth'`, and preserve master's unrelated
`import { ROOT_ORG_ID } from '../migrations/015_seed_root_platform_organization'`
(used at the app-create call site). Both /health and / call sites already use
the new checkAppHealth(app, origin) signature.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012QT7kQbiqEAZMgTzRTSj8i
izzywdev
left a comment
There was a problem hiding this comment.
Approving on verified-green CI (head d3dcff3a).
Review
- Backend-only change. Replaces a health probe that returned
response.status < 500— so a 404 counted as healthy — withappHealth.ts, which per integration type requires a module-federation remote entry to return 200 AND a JavaScript body (catching the SPA-fallback "200 that isn't"), and everything else to return< 400. Correct and well-motivated by the production 0-of-16 probe. - Security hardening is sound:
probeOriginnever derives the probe target from caller-controlledHost/x-forwarded-*headers, and fails closed (reports unhealthy naming the missingFEDERATION_PROBE_ORIGIN) rather than guessing — a probe whose target the caller can pick is not a health check. - Absolute
remote_urls are probed verbatim; the JS content-type short-circuit avoids downloading multi-hundred-KB bundles. Additivereason/httpStatusfields, no contract covers this route. - Test suite is weighted toward the must-be-unhealthy cases (404/503/200-HTML by content-type and by body sniff, missing
remote_url, network error) — i.e. non-vacuous.
Merge-conflict resolution I applied: master had since landed a competing inline health check (from a rival PR) that only probed absolute remote_urls and skipped the same-origin-relative form migration 011 made the default. I resolved in favor of this PR's superior extracted module and preserved master's unrelated ROOT_ORG_ID import; both call sites use the new checkAppHealth(app, origin) signature.
CI: all 18 checks green on d3dcff3a, including Backend Tests, Security Scanning, OIDC/Google/sign-in E2E, Harden Gate, and every gate-*. The Backend-Test failures seen on an earlier head were pre-existing master regressions since fixed by #811 and merged in here — not this PR's.
The separate visibility-scoping divergence this PR's description flags is correctly left for its own change.
Generated by Claude Code
📋 Description
checkAppHealthinbackend/src/routes/apps.tswas:So an app whose URL returned 404 was reported healthy. A production probe of all 16 registered remotes found 0 of them serving a module, and the portal calling every one green:
/apps/<slug>/— no ingress route resolvesA check that cannot separate "nothing is served here" from "this works" is not a health check. This one reported the entire federated surface green while none of it loaded.
🔧 Implementation Details
Backend only. No frontend, SDK, or deploy changes.
Health now means something different per integration type:
module-federation— fetch the registered remote entry and require HTTP 200 and a JavaScript body. The content-type/HTML test is not belt-and-braces: it is the only thing that catches the SPA-fallback case, where the status line says 200 and the bytes areindex.html. Same rule asscripts/check-federated-assets.mjs, deliberately, so the CI probe and the runtime check cannot disagree about what "serving" means.iframe,spa,web-component) — fetch the app URL and require< 400. A 404 on an app's own root means nothing is there.remote_url resolution
remote_urlmay be same-origin-relative (/apps/<slug>/remoteEntry.js, the stored form since migration 011) or absolute (legacy cross-origin registrations). Relative resolves against the request origin, matchingfrontend/src/utils/loadFederatedApp.tsin the browser. Absolute is probed verbatim, so a cross-origin registration is judged on the host it actually names — probing those same-origin is what made the first production probe scorefuzekeysas a 404 for a path it never claimed. That was a fault in the instrument, and there is a test pinning it.Unhealthy is no longer an unexplained
falseThe response carries
reasonandhttpStatus. 404 (no route), 503 (route up, backend down) and 200-with-HTML have different fixes and different owners; the old boolean threw that away. Both fields are additive, and no OpenAPI contract covers this route.Not downloading the bundle
A JavaScript content-type returns without reading the body. A
remoteEntrybundle is hundreds of KB and this runs once per app on every listing request — downloading it to confirm what the header already said is the expensive way to learn nothing. The body is sniffed only when the content-type is inconclusive.Extracted for testability
Moved to
backend/src/routes/appHealth.ts, with no express and no database import.routes/apps.tsneeds a live Postgres to import, which is why this had no unit test before.🧪 Testing
backend/tests/appHealth.test.ts, weighted deliberately toward the cases that must report unhealthy — 404, 503, 200+HTML by content-type, 200+HTML by body sniff, missingremote_url, network error, and 404 on a non-federated root. A suite that only asserted "a working app is healthy" would have passed against the old code too. It also asserts the bundle is not downloaded when the content-type settles it, and that an absoluteremote_urlis probed verbatim.What I verified, and what I did not:
tsc --noEmitintroduces exactly one name beyond the pre-existing baseline (URL). EveryTS2304in this file is a missing-@types/nodeartefact of my worktree — confirmed by type-checking the unmodifiedmasterversion in the same worktree and getting the same four.jestwas NOT run —node_modulesis not installed in this environment. CI on this PR is the proof for the test suite.🚨 Breaking Changes
None for any caller that exists today, but one thing to know:
Nothing in
frontend/orsdk/passeshealthyOnly(verified by grep), so the portal lists apps regardless of health and this fix cannot shrink what users see. That is called out in a comment at the call site, because with production at 0-of-16 serving, a future caller turning it on would filter the list to nothing — which would be the check telling the truth, but should be a deliberate choice rather than a surprise.📝 Additional Notes
A separate defect this surfaced — reported, not fixed here
There are two implementations of the same visibility-scoping rule, and they disagree:
backend/src/routes/apps.tsscopeAppsQuery—visibility IN ('public','marketplace') OR organization_id IN (memberOrgIds). An app withorganization_id IS NULLmatches neither branch and is invisible to everyone.backend/applications/src/app-registry/service.ts:235—.whereIn('visibility', [...]).orWhereNull('organization_id'), which does show org-less apps.All 17 products with a
registration/manifest.jsonregister asorganizationorprivatevisibility; not one ispublic/marketplace. So which of these two runs decides how many apps a user sees. Both are mounted —backend/src/index.ts:315mountsroutes/apps, whilefrontend/src/services/api.tscomments that the applications-service owns/api/apps— and I could not determine from here which one production's ingress actually routes to. Reporting the divergence rather than guessing which is authoritative. It is a plausible cause of the portal listing fewer apps than are registered, and it wants its own fix.Deployment Notes
No migration, no dependency change. One optional new env var,
FEDERATION_PROBE_ORIGIN, overriding the origin a relativeremote_urlresolves against — needed if the API pod cannot reach its own public hostname from inside the cluster. When it cannot, the probe reports unhealthy with the network error named, rather than reporting healthy the way the old one did.