Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit4abf6f1

fix(api): return JSON, not the HTML error page, on unmatched machine routes

fix(api): return JSON, not the HTML error page, on unmatched machine routes

The production-readiness gate's "api-json" check has been failing against
the live site: GET /api/<anything-unmatched> answered 404 with
`content-type: text/html`, so REST, MCP, SCIM and GraphQL clients got a
full HTML document and died on JSON.parse instead of reading the error.

Root cause was ordering, not the 404 handler. webRoutes owns the
`/:owner/:repo` catch-all and is mounted last, so a two-segment path like
`/api/nope` matched as owner="api", repo="nope" and was answered with the
HTML "repository not found" page — app.notFound never ran at all.

- Terminate the machine roots (/api, /mcp, /graphql, /v2, /npm, /scim,
  /.well-known) with a JSON 404 registered immediately before webRoutes,
  so they can no longer fall through into the repo catch-all.
- Also content-negotiate in app.notFound and app.onError, covering
  one-segment paths and any client that sends Accept: application/json.
- Root matching is exact-or-path-segment, so /mcpanything stays an HTML
  profile page rather than being captured as the /mcp surface.

/v2/* and /npm/* keep their own routers' spec-shaped errors — those
subtrees are fully claimed, so the fallback is only a safety net there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 27, 2026Parent: 38de014
2 files changed+12304abf6f18d9d42825ad28496a75b2c9490eb8ddb3
2 changed files+123−0
Addedsrc/__tests__/api-error-format.test.ts+63−0View fileUnifiedSplit
1/**
2 * Machine surfaces must fail as JSON, not HTML.
3 *
4 * The global app.notFound / app.onError handlers render the shared HTML
5 * error pages. Before this was gated, an unmatched /api/* path returned a
6 * full HTML document with `content-type: text/html`, so REST, MCP and OCI
7 * registry clients hit a JSON.parse error instead of a readable envelope.
8 * The production-readiness gate (scripts/production-readiness.mjs, "api-json")
9 * checks exactly this against the live site.
10 */
11
12import { describe, expect, it } from "bun:test";
13import app from "../app";
14
15async function get(path: string, headers: Record<string, string> = {}) {
16 return app.fetch(new Request(`http://localhost${path}`, { headers }));
17}
18
19describe("unmatched machine-surface paths return JSON", () => {
20 // `/v2/*` (OCI registry) and `/npm/*` are deliberately absent: their own
21 // routers claim the whole subtree and emit their own spec-shaped JSON
22 // errors, so the app-level fallback never fires for them. They stay in
23 // JSON_ROOTS purely as a safety net.
24 const jsonPaths = [
25 "/api/definitely-not-real",
26 "/api/v2/definitely-not-real",
27 "/mcp/definitely-not-real",
28 "/graphql/definitely-not-real",
29 "/scim/definitely-not-real",
30 "/.well-known/definitely-not-real",
31 ];
32
33 for (const path of jsonPaths) {
34 it(`${path} → 404 application/json`, async () => {
35 const res = await get(path);
36 expect(res.status).toBe(404);
37 expect(res.headers.get("content-type")).toContain("application/json");
38 const body = await res.json();
39 expect(body.error).toBe("Not found");
40 expect(body.path).toBe(path);
41 });
42 }
43
44 it("a deep unmatched API path is JSON too", async () => {
45 const res = await get("/api/v2/repos/nope/nope/nope/nope");
46 expect(res.status).toBe(404);
47 expect(res.headers.get("content-type")).toContain("application/json");
48 });
49});
50
51describe("HTML surfaces are unaffected", () => {
52 it("a path that merely starts with an API root's letters stays HTML", async () => {
53 // "/mcpanything" must NOT be captured by the "/mcp" machine surface —
54 // it is a one-segment path and belongs to the /:owner profile route.
55 const res = await get("/mcpanything", { accept: "text/html" });
56 expect(res.headers.get("content-type")).toContain("text/html");
57 });
58
59 it("a normal repo path still renders HTML", async () => {
60 const res = await get("/someowner/somerepo", { accept: "text/html" });
61 expect(res.headers.get("content-type")).toContain("text/html");
62 });
63});
Modifiedsrc/app.tsx+60−0View fileUnifiedSplit
11import { Hono } from "hono";
2import type { Context } from "hono";
23import { logger } from "hono/logger";
34import { cors } from "hono/cors";
45import { compress } from "hono/compress";
942943// Mounted BEFORE webRoutes so /share/* doesn't fall through to the /:owner profile route.
943944app.route("/", shareRoutes);
944945
946// Machine surfaces must fail in the format their clients parse. An
947// unmatched /api/* path used to fall through to the HTML 404 page, so a
948// REST/MCP/registry client asking for JSON got a full HTML document and
949// blew up on JSON.parse instead of seeing a readable error. Decided by
950// path root (these mounts are JSON-only by contract) or by an Accept
951// header that asks for JSON and not HTML.
952const JSON_ROOTS = [
953 "/api",
954 "/mcp",
955 "/graphql",
956 "/v2", // OCI registry — the spec mandates a JSON error envelope
957 "/npm",
958 "/scim",
959 "/.well-known",
960];
961
962function wantsJson(c: {
963 req: { path: string; header: (n: string) => string | undefined };
964}) {
965 const path = c.req.path;
966 // Match the root exactly or as a path segment — "/mcp" and "/mcp/x" are
967 // machine surfaces, "/mcpanything" is not.
968 if (JSON_ROOTS.some((r) => path === r || path.startsWith(r + "/"))) return true;
969 const accept = c.req.header("accept") ?? "";
970 return accept.includes("application/json") && !accept.includes("text/html");
971}
972
973function jsonNotFound(c: Context) {
974 return c.json(
975 { error: "Not found", path: c.req.path, method: c.req.method },
976 404
977 );
978}
979
980// Machine-surface 404 fallback — MUST be registered before webRoutes.
981// webRoutes owns the `/:owner/:repo` catch-all, so an unmatched two-segment
982// path like `/api/nope` was being read as owner="api", repo="nope" and
983// answered with the HTML "repository not found" page. REST/MCP/OCI clients
984// then choked on HTML where they expected a JSON envelope. Terminating the
985// machine roots here keeps them JSON-only by contract.
986for (const root of JSON_ROOTS) {
987 app.all(root, (c) => jsonNotFound(c));
988 app.all(`${root}/*`, (c) => jsonNotFound(c));
989}
990
945991// Web UI (catch-all, must be last)
946992app.route("/", webRoutes);
947993
948994// Global 404 — BLOCK O2 routes the shared `NotFoundPage` view so
949995// the markup stays consistent with /500 and the admin /403 page.
950996app.notFound((c) => {
997 if (wantsJson(c)) {
998 return c.json(
999 { error: "Not found", path: c.req.path, method: c.req.method },
1000 404
1001 );
1002 }
9511003 const user = c.get("user") ?? null;
9521004 return c.html(
9531005 <NotFoundPage user={user} method={c.req.method} path={c.req.path} />,
9731025 process.env.NODE_ENV !== "production" && err && err.message
9741026 ? err.message
9751027 : undefined;
1028 // Same reasoning as the 404 above: an API/MCP client must get a JSON
1029 // envelope it can parse, not the HTML error page.
1030 if (wantsJson(c)) {
1031 return c.json(
1032 { error: "Internal server error", requestId, ...(trace ? { trace } : {}) },
1033 500
1034 );
1035 }
9761036 return c.html(
9771037 <ServerErrorPage user={user} requestId={requestId} trace={trace} />,
9781038 500
9791039