Skip to content

πŸ› Express catch-all 404 covers GET only β€” other verbs fall through to Express's default HTMLΒ #3978

Description

@PierreBrisorgueil

Problem

#3975 replaced the implicit-200 catch-all with a content-negotiated 404. The handler is registered on app.get only:

// lib/services/express.js β€” initModulesServerRoutes
app.get('/{*path}', (req, res) => {
  const isApiPath = /^\/api(\/|$)/i.test(req.path);
  if (isApiPath || req.accepts(['html', 'json']) === 'json') {
    return res.status(404).json({ error: 'not_found' });
  }
  res.status(404).send('<center><br /><h1>404</h1><h3>Not Found</h3></center>');
});

initModulesServerRoutes ends there, and initErrorRoutes only registers a 4-arity error handler, which fires on next(err) and never on an unmatched route. So nothing handles unmatched POST / PUT / PATCH / DELETE: they fall through to Express's default finalhandler and return HTML (Cannot POST /api/…).

That contradicts the stated intent of the change, quoted from its own comment:

API consumers and automated agents get a proper JSON error instead of a false-positive success.

An API client gets JSON on a bad GET and HTML on a bad POST.

Not theoretical

MIGRATIONS.md documents removed routes that are POSTs β€” POST /api/organizations/:organizationId/invites, POST /api/invites/:token/accept. A client still calling those is exactly the audience #3975 targeted, and it currently receives HTML.

Why it slipped

The nine tests added in lib/services/tests/express.notfound.unit.tests.js are all GET. No verb coverage.

Proposed fix

  • Register the catch-all with app.all instead of app.get, so every unmatched verb gets the same content-negotiated 404. The explicit app.get('/') root route stays registered before it and is unaffected.
  • Add tests for POST / PUT / PATCH / DELETE on both /api/* and non-API paths.
  • Check OPTIONS explicitly. app.all would also match preflight requests. The cors middleware normally answers preflight before routes are reached, but that ordering needs a test rather than an assumption β€” it is the one real risk in this change.

Second gap from the same change: no migration note

#3975 is a behavioural break for any consumer that treated a 200 on an unknown path as success, and it shipped with no MIGRATIONS.md entry. A short note would help anyone whose readiness/health check happens to poll a path that is not a declared route β€” that check silently flips from passing to a hard timeout, while the server is perfectly healthy.

  • Add a MIGRATIONS.md entry: unmatched paths no longer return 200; health checks must target a declared route such as /api/health.

Worth noting this repo's own CI already polls /api/health, so it could not observe the regression it introduced.

Small enough to land as a single PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions