Skip to content

fix(apps): a 404 is not healthy — probe the module, not "did anything answer" - #806

Merged
izzywdev merged 5 commits into
masterfrom
claude/app-health-honest
Aug 26, 2026
Merged

fix(apps): a 404 is not healthy — probe the module, not "did anything answer"#806
izzywdev merged 5 commits into
masterfrom
claude/app-health-honest

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📋 Description

checkAppHealth in backend/src/routes/apps.ts 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:

result count meaning
HTTP 404 12 nothing mounted at /apps/<slug>/ — no ingress route resolves
HTTP 503 3 route exists, no healthy backend
HTTP 200 + HTML 1 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. 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 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 (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_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 frontend/src/utils/loadFederatedApp.ts 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. That was a fault in the instrument, and there is a test pinning it.

Unhealthy is no longer an unexplained false

The response carries reason and httpStatus. 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 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 for testability

Moved to backend/src/routes/appHealth.ts, with no express and no database import. routes/apps.ts needs 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, 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. It also asserts the bundle is not downloaded when the content-type settles it, and that an absolute remote_url is probed verbatim.

What I verified, and what I did not:

  • tsc --noEmit introduces exactly one name beyond the pre-existing baseline (URL). Every TS2304 in this file is a missing-@types/node artefact of my worktree — confirmed by type-checking the unmodified master version in the same worktree and getting the same four.
  • jest was NOT runnode_modules is 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/ or sdk/ passes healthyOnly (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.ts scopeAppsQueryvisibility IN ('public','marketplace') OR organization_id IN (memberOrgIds). An app with organization_id IS NULL matches 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.json register as organization or private visibility; not one is public/marketplace. So which of these two runs decides how many apps a user sees. Both are mounted — backend/src/index.ts:315 mounts routes/apps, while frontend/src/services/api.ts comments 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 relative remote_url resolves 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.

… 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
@github-actions github-actions Bot added the auto-merge Enable squash auto-merge once CI passes label Aug 25, 2026
@github-actions
github-actions Bot requested a review from izzywdev as a code owner August 25, 2026 08:31
@github-actions github-actions Bot added the auto-merge Enable squash auto-merge once CI passes label Aug 25, 2026
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
@github-actions
github-actions Bot enabled auto-merge (squash) August 25, 2026 10:07
Comment thread backend/src/routes/appHealth.ts Fixed
: 'text/html,application/json'
)
if (!probe.ok) {
console.log(`Health check failed for ${app.name} (${target}):`, probe.message)
izzywdev
izzywdev previously approved these changes Aug 26, 2026

@izzywdev izzywdev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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
claude added 2 commits August 26, 2026 05:31
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 izzywdev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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 — with appHealth.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: probeOrigin never derives the probe target from caller-controlled Host/x-forwarded-* headers, and fails closed (reports unhealthy naming the missing FEDERATION_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. Additive reason/httpStatus fields, 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

@izzywdev
izzywdev merged commit 95ae2cf into master Aug 26, 2026
59 checks passed
@izzywdev
izzywdev deleted the claude/app-health-honest branch August 26, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-merge Enable squash auto-merge once CI passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants