---
title: Exclude /api routes from the league SPA fallback
type: task
status: completed
description: >-
  The league SPA fallback serves index.html with HTTP 200 for any authenticated GET/HEAD to an
  unmatched /api/* path, because render-html.mjs bails only on method, never on path. Code-verified;
  production execution pending a fresh authenticated token (the prior refutation probe was itself
  invalid). Add an /api 404 JSON guard before the SPA catch-all.
base_uri: user:task/league/exclude-api-routes-from-spa-fallback.md
created_at: '2026-09-19T18:53:12.803Z'
entity_id: e69a6d26-d9e6-457f-891c-f6b3af4a4685
finished_at: '2026-09-19T18:55:28.975Z'
observations:
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[user:task/league/fix-scoreboard-poll-survives-transient-db-errors.md]]
  - relates [[███████████████████████████████-█████████]]
started_at: '2026-09-19T18:53:36.092Z'
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-09-19T20:45:41.510Z'
visibility_analyzed_at: '2026-11-05T23:49:12.533Z'
---

# Exclude /api routes from the league SPA fallback

## Plan

### 1. Confirm the defect

`repository/active/league/api/index.mjs` mounts every API route under a distinct `/api/<prefix>`,
then the SPA catch-all at line 394 (`api.use('/*', create_render_html_middleware(...))`). The
blanket auth guard at line 280 (`api.use('/api/*', ...)`) 401s **unauthenticated** callers, so the
fallback is only reachable for **authenticated** requests to an unmatched `/api/*` path — which then
reach the SPA renderer with no intervening `/api` 404 handler. `render-html.mjs:47` bails only on
`req.method !== 'GET' && req.method !== 'HEAD'`, never on `req.path` starting with `/api`, so the
renderer answers those with `index.html`, status 200, `text/html`.

Result: an authenticated client that fetches a wrong API path receives `<!DOCTYPE html` where JSON
was expected. A `fetch().json()` call throws `SyntaxError: Unexpected token '<', "<!DOCTYPE "... is
not valid JSON` — exactly the fingerprint of signal 130996 (league-client,
40f0742aa938b768). Worth noting signal #123576 (in the code comment at index.mjs:320-322) is the
*inverse* and already fixed: a missing **bundle asset** under `/dist` 404s rather than reaching the
catch-all with 200 HTML.

### 2. Mount an `/api` 404 guard before the SPA fallback

Mount an `/api/*` 404 handler after all API routes and before the SPA fallback:

```js
// Unmatched /api path after every concrete route — answer JSON 404, never the SPA HTML.
api.use('/api/*', (req, res) => res.status(404).json({ error: 'Not found' }))
```

Place it immediately before `api.use('/*', create_render_html_middleware(...))` so only unmatched
`/api` paths reach it.

### 3. Verify

1. Authenticated GET to `/api/definitely-not-a-real-endpoint` returns `404 application/json`,
   not `200 text/html`.
2. Unauthenticated GET to the same path still returns `401` (the blanket `!req.auth` guard fires
   first — unchanged).
3. `/api` paths with a valid route (e.g. `/api/status`) still return their normal JSON.
4. SPA routes (`/leagues/1`) still render `index.html` — the catch-all is untouched.

Tests live in `repository/active/league/test/`; follow `docs/guides/api.md` and
`docs/guides/test.md`.
