Skip to content

[Backend][API] Unknown /api/* paths return 200 + index.html — the SPA fallback swallows API 404s #1971

Description

@Chris0Jeky

Found by the 2026-08-22 horizon-journey golden run — a 44-capability live end-to-end session against a running instance. Sibling of tracker #1947. Finding H-09.

The defect

Every non-existent /api/* route falls through to the SPA handler and returns 200 OK with the app
shell
instead of a 404.

GET /api/definitely-not-a-real-endpoint-hzn
→ 200   content-type: text/html   body: "<!doctype html>\n<html lang=\"en\">…"

Same for /api/captures, /api/proposals, /api/inbox, /api/workspace/review — none of which exist
(the real proposals endpoint is /api/automation/proposals).

Source. backend/src/Taskdeck.Api/Extensions/PipelineConfiguration.cs:170:

app.MapFallbackToFile("index.html").AllowAnonymous();

The fallback is unscoped — it matches any unmatched path, /api/* included.

The comment directly above it (lines 165-169) asserts the opposite:

"SPA fallback: any route not matched by a controller or hub endpoint returns index.html … API (/api/*) and hub (/hubs/*) routes are matched above and never reach this fallback."

That is true only for routes a controller actually declares. An /api/* path with no matching
controller route reaches the fallback and gets a 200. So a permanent code comment records a safety
property the code does not have — worth fixing in the same change as the behaviour, since a future reader
will otherwise rely on it.

Why it matters

Any API consumer — the MCP server, scripts, tests, a future integration, or an agent probing the surface —
gets a false success for a typo'd, renamed, or removed endpoint. Client code that checks
response.ok before parsing treats a 404 as a 200 and then fails on JSON parsing at a distance from the
real cause, which is exactly the silent-failure shape the recent review work has been trying to eliminate
(it was independently flagged as the enabler during the PR #1956 review).

It also makes endpoint discovery actively misleading: during this run /api/proposals looked like an
existing endpoint returning an empty result, when it simply does not exist.

Because a removed or renamed endpoint returns 200 rather than 404, contract drift is invisible to any
caller that does not parse the body — including tests written to assert "the call succeeded".

Acceptance criteria

  • Unknown paths under /api/ return 404 with a ProblemDetails body and content-type: application/problem+json, never text/html.
  • The SPA fallback is scoped so it cannot match /api/*; /hubs/* is treated the same way.
  • Existing API routes, the bare / route, and deep client-side routes (/workspace/review, /workspace/boards/{id}) still serve the SPA shell — the fix must not break Vue Router deep links or the / handling added for Packaged binary: GET / returns 401 instead of the SPA shell (bare root not served under global auth fallback) #1181.
  • Unauthenticated requests to a real API route keep returning 401, not 404 — the change must not alter the auth outcome ordering under the global FallbackPolicy (Security gate + config hardening: required secret/SAST/dependency scans, CORS fail-closed, JWT floor, FallbackPolicy, bundle gate #1132 AC4).
  • The comment at PipelineConfiguration.cs:165-169 is corrected to describe what the pipeline actually guarantees after the fix.
  • Integration test: GET /api/<nonexistent> returns 404 + ProblemDetails, asserted for both an authenticated and an unauthenticated caller.
  • Integration test: a known client-side route still returns the SPA shell with 200, so the fallback is proven still functional.
  • Integration test: /hubs/<nonexistent> does not return the SPA shell.

Refs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions