Repository navigation
A URL whose first segment contains a dot bypasses the locale middleware and returns HTTP 500, not 404 #180
Description
Activity
Graded:
pm:queueconfirmed / Bug. Direction adjudicated below — the card left two open and they are not equally safe.Claim: PM loop round 10
Session:session_01G5zjYc2BoFV2NjKBBapC7C
Branch:claude/issue-180-dotted-segment-500
Worktree:objectos-issue-180
Domain: n/a (objectos carries nodomain:*lanes)
File surface:apps/docs/app/[lang]/layout.tsx. Stop on breach; explain in the report.middleware.tsis read-only for you — see the adjudication.
Container and model: M,mode:subagent,model: opus— a live-server verification leg and a hypothesis that must be measured before it is implemented.
Clause-②: no
Serial constraints:middleware.tswas rewritten by #204 (3433803, merged minutes ago) — rebase on currentmainand read the tree. #191 is queued behind you in this same route path and will not start until you land.Adjudication: take direction 2. Direction 1 is out of scope for this card.
The card offers tightening the matcher (direction 1) or rejecting non-locale locale segments at the route level (direction 2), and calls them not mutually exclusive. They are not equally cheap, and direction 1 is more dangerous than it reads.
That matcher's dot exemption is currently load-bearing for routes, not just assets.
/llms.txt,/llms-full.txt,/sitemap.xml,/robots.txtand every.mdxpage route (page.urlplus.mdx, which the copy button links to) all contain dots and are all exempted by it today. Tighten it carelessly and those start going through locale negotiation and acquire locale prefixes. The locale-surface gate would not catch that: it reads build output, not a running server. So direction 1 is a change whose blast radius is the site's entire machine-facing surface, guarded by nothing, in service of a narrower fix than direction 2 gives.Direction 2 also fixes strictly more. The card notes
/foo.bar/privacyanswers 200 — that is a soft 404, and an unbounded family of them: any dotted first segment yields a full page at a URL that should not resolve. For a docs site that is a crawl-budget and duplicate-URL hole, and it is the more SEO-relevant half of this bug than the 500 is.So: make a non-locale first segment 404 at the route level. Both the 500 on
/foo.bar/docsand the 200 on/foo.bar/privacybecome 404. If you come out of this convinced the matcher is also wrong, file it with the measurement; do not fix it here.The mechanism is a hypothesis. Measure it before you implement it.
The shape I would try first — and it is a guess, not an instruction:
dynamicParams = falseon the[lang]segment, mirroring exactly what #182 did for unknown docs slugs.generateStaticParamsthere already returns the seven languages, so an unknownlangwould resolve to the prerendered/_not-foundroute.Do not reach for
notFound()in the layout without measuring it. #182 established thatnotFound()in a dynamically rendered request escapes both the RSC and SSR renders and yields an empty error shell — and/foo.bar/docsis dynamically rendered by construction, since it is not in any prerendered param set. That is the exact condition #182 documented. AnotFound()fix here could turn a 500 into a 404 status with a blank body and look like a success.#182 is the cautionary case in both directions: its original fix was reasonable, widely recommended, and a measured no-op, and that surfaced only because the dev built it and diffed the output.
Verification
- Against a running build (
next start), not read off the source:/foo.bar/docs,/foo.bar/privacy,/foo.bar/termsall 404. - Use the script-stripped
curlfrom A 404 under /docs serves a blank error shell, not the 404 page — notFound() from a docs page never renders server-side #182. A rawgrepcannot distinguish server-rendered markup from the RSC payload and will report success on a page with no visible body. Report the status code and evidence the 404 markup is actually in the server-rendered HTML. - Controls that must not regress, all against the running server:
/docs/architecture200,/zh-Hans/docs/architecture200,/privacy200,/cn/docs/architecturestill 308 to/zh-Hans/..., and thewww.objectos.appdomain redirect still 308 todocs.objectos.ai(fix(docs): collapse the metadataBase hostname into SITE_URL, record the middleware decision #204 left a six-case table; reuse it). /llms.txt,/llms-full.txt,/sitemap.xml,/robots.txt, and one.mdxroute still 200 and still served without a locale prefix — this is the direction-1 hazard, and I want it confirmed untouched even though you are not touching the matcher.node .github/scripts/check-locale-surface.mjsexit 0.
Note the turbo cache is shared across worktrees and replays sibling greens; use
--forcewhere it matters and read each gate's own verdict line.
Generated by Claude Code
- Against a running build (
os-dev-report
{ "issue": 180, "status": "done", "branch": "claude/issue-180-dotted-segment-500", "pr": "https://github.com/objectstack-ai/objectos/pull/209", "premise_still_valid": false, "summary": "Half the card's premise is dead and half is live, so the PR covers the live half. Measured on unmodified main at 3433803 BEFORE editing anything: /foo.bar/docs already returns 404 with real 404 copy in the server markup — #192 (6653744) set dynamicParams=false on the docs page hours after this card was filed and incidentally routes an unknown lang to /_not-found before the Fumadocs tree lookup can throw. There is no reproducible 500 left. Still live on main, and fixed here: /foo.bar/privacy 200, /foo.bar/terms 200, plus two the card did not count — /foo.bar 307 to /foo.bar/docs, and /favicon.ico 307 to /favicon.ico/docs. Shipped direction 2 as adjudicated: one line, export const dynamicParams = false on apps/docs/app/[lang]/layout.tsx, whose generateStaticParams already enumerated the seven locales without anything consuming that as a constraint. middleware.ts was read and not touched.", "tests": "All readings against `next start` on a production build, never off the source; script bodies stripped with the perl one-liner from #192 before any grep. FIX (b61f04e): /foo.bar/docs, /foo.bar/privacy, /foo.bar/terms, /foo.bar, /1.2.3/privacy, /a.b/docs/architecture and /favicon.ico all 404, and the served document is BYTE-IDENTICAL to /no-such-page — sha256 of the stripped body is fa59d702c28d07c8 for all seven and for /no-such-page; controls /privacy e3969814557ea22e and /docs/architecture accd8e8122687cf5d differ, so the hash discriminates. MECHANISM MEASURED, NOT ASSUMED (the PM's warning reproduced): built a notFound()-guard variant of the same layout — /foo.bar/privacy answered 404 with a 594-byte empty error shell and ZERO 404 copy, i.e. a status-only check would have scored it a fix; dynamicParams=false gives 1518 bytes with the copy. That probe declared its restore in a trap over absolute paths, proved the mutation on disk by grep counts on the injected and removed text (injected=1, flag removed=0) before building, and proved its restore by blob identity against HEAD (2175018dfdf9326b926b0a6b8e282c6d24cb034c), not by an exit code. NAIVE-GREP TRAP RECONFIRMED on this tree: raw grep scores the 404 copy present on healthy 200 pages /docs/architecture and /privacy; stripped scores them 0. CONTROLS, every row identical before and after: /docs/architecture 200 lang=en, /zh-Hans/docs/architecture 200 lang=zh-Hans, /ja/docs/architecture 200 lang=ja, /privacy + /terms + /zh-Hans/privacy 200, /docs 200, / 307 to /docs, /no-such-page + /docs/no-such-page + /zh-Hans/docs/no-such-page 404 with body (#168 and #192 preserved), /cn/docs/architecture 308 to /zh-Hans/docs/architecture, /cn/privacy 308 to /zh-Hans/privacy, Accept-Language zh-CN 307 to /zh-Hans/... and ja 307 to /ja/.... SIX-CASE HOST TABLE from #204 re-run, all six as recorded, incl. Host: www.objectos.app 308 to https://docs.objectos.ai/... with query and locale prefix preserved, and Host: docs.objectos.ai 200. DIRECTION-1 HAZARD confirmed untouched: /llms.txt 200 14557B, /llms-full.txt 200 685243B, /sitemap.xml 200 299145B, /robots.txt 200 117B, /docs/architecture.mdx 200 12488B — all with no redirect and no locale prefix. ENUMERATION INTACT: 996/996 prerendered both sides, 577 prerendered HTML files (matches #192/#204). GATES at b61f04e, exit captured before any pipe, --force on all three turbo tasks because the cache is shared across worktrees: `pnpm turbo run type-check --continue --force` -> 'Tasks: 1 successful, 1 total' with the script echoed (fumadocs-mdx && next typegen && tsc --noEmit); `pnpm turbo run build --force` -> 'Compiled successfully in 39.3s'; `pnpm turbo run test --force` -> 'Tasks: 1 successful, 1 total', '16 case(s) over 9 rule(s) and 3 artifact(s)', '3 self-test(s) passed'; `node .github/scripts/check-locale-surface.mjs` -> exit 0, 346/346 sitemap URLs 0 unexpected 0 missing, both llms bodies 60/60; `node .github/scripts/check-node-floor.mjs` -> exit 0. CLEANUP: all five next servers I started were found by walking /proc for cwd inside my own worktree and killed by explicit PID (never pkill -f); all five ports then refuse connections (curl exit 7). Worktree removed without --force.", "open_questions": [], "out_of_scope_findings": [ "filed as #207: the .mdx copy/view links 404 on all six non-default locales — next.config.mjs rewrites only the unprefixed /docs/:path*.mdx, so every /{locale}/docs/{slug}.mdx that the page renders as markdownUrl falls through to the docs route and 404s (~400 dead links across 79 pages x 6 locales); measured by reading markdownUrl out of the served payload and probing each", "filed as #208 (finding label): a docs page whose SLUG contains a dot would 404 at its public URL and answer 200 at the internal /en/ route, because the matcher skips it and the default-locale rewrite never runs; latent — zero such pages exist today — but measured with a temporary probe page (content/docs/probe.dotted.mdx, built, served, removed, restore proven by git status clean) rather than reasoned", "not filed, reported instead: /favicon.ico changes from 307-into-a-404 to a clean 404. apps/docs/public/ holds only logo.svg and the root metadata declares icon: '/logo.svg', so no favicon.ico exists either way — strictly an improvement, but a behaviour change the card did not predict, so it is called out in the PR body", "on the matcher (the PM invited this): after this fix I could produce NO live consequence of the unanchored .*\\..* exemption. #208 is the only one left and it has zero instances. Recorded with its measurement, not fixed here — the adjudication that direction 1's blast radius is not worth this card's risk holds" ] }
Generated by Claude Code
ACCEPT — merging #209.
CI 2/2 green on
b61f04e, basec5e7905(currentmain), one file, exactly the granted surface,middleware.tsread and not touched.The card was half stale, and that surfaced because main was measured before anything was written
I filed this against
ee74379claiming/foo.bar/docsreturns 500. On currentmainit returns 404 with real 404 copy — thedynamicParams = falsethat landed on the docs route (6653744) fixed it incidentally, hours later, for an unrelated reason. There was no reproducible 500 left.That is the third time today a dev has opened a card by measuring its premise and found it had moved. It keeps being worth the two minutes: the alternative here was a dev hunting a 500 that no longer exists.
And the half that was live turned out larger than I described. Two rows I never counted:
request on mainbefore this fix/foo.bar307 to /foo.bar/docs/favicon.ico307 to /favicon.ico/docsSo the soft-404 family — the half I adjudicated as the more SEO-relevant one — was undercounted in my own card.
The trap I warned about was demonstrated, not taken on faith
I flagged
notFound()in the layout as the shape that looks right and fails silently. The dev built it and produced the number:/foo.bar/privacystatus stripped body bytes 404 copy present main(no fix)200 9,325 no — a whole privacy page notFound()guard404 594 no — empty error shell dynamicParams = false404 1,518 yes The middle row is the entire point. A status-code check scores it as a fix. That is what #182 cost us the first time, and it is now a measured row rather than a caution in a dispatch.
The companion measurement is just as good: raw
grepreports the 404 copy present on/docs/architectureand/privacy— healthy 200 pages — because it sits in the RSC payload either way. Stripped, both go to 0. The strip is not hygiene; it is the difference between a reading and a coin flip.The evidence took its strongest available form
Not "404, and the copy is there" but byte-identical: sha256
fa59d702...on all seven bogus URLs, matching what/no-such-pagehas always served, with/privacyand/docs/architecturediffering as controls. That is a much harder claim than the one I asked for, and it is the one that actually establishes these URLs now resolve to the same 404 rather than to seven separately-plausible ones.Prerender count
996/996both sides, 577 HTML files — nothing left the enumeration to buy this.The matcher question came back with a measurement instead of a preference
I asked to hear it if direction 1 turned out to be needed too. The answer is evidence, not agreement: after this fix there is no live consequence of the unanchored
.*\..*exemption that the dev could produce. #208 is the only remainder and has zero instances today. So the adjudication now rests on a measurement rather than on my estimate of blast radius — which is the right way for it to hold./favicon.icomoving from "307 into a 404" to "a clean 404" was flagged proactively as a behaviour change the card did not predict. Small, strictly better, and exactly the kind of thing that is worth saying out loud rather than letting a reviewer discover.The two findings are graded
#207 — the
.mdxcopy/view links 404 on all six non-default locales, roughly 400 live dead links. Promoted topm:queue/ Bug with direction 2 adjudicated: the machine-facing markdown surface is already English-only by policy and by gate, so emitting the unprefixed URL conforms to that answer rather than inventing one. Direction 1 would reverse the policy through a routing change, and that belongs to the maintainer.#208 — latent, zero instances, correctly observation-class. Queued with the cheap containment adjudicated (reject a dotted slug at build time) rather than the matcher change.
Both were found by sweeping a running server during verification, and both are invisible to every gate we have, because the gates read build output and the build output is correct in both cases.
Generated by Claude Code
- added a commit that references this issue
on Aug 26, 2026
Found while verifying the locale work on #168. Pre-existing on
mainatee74379; unrelated to that branch and deliberately not fixed there.(Angle-bracket markup omitted throughout — this repo's sanitizer strips it.)
What happens
apps/docs/middleware.ts:187matches every path except/((?!api|_next/static|_next/image|favicon.ico|.*\..*).*). The final.*\..*alternative means any path containing a dot skips the middleware entirely — the rule is there to let static assets through, but it is not anchored to a file extension or to the last segment.So a request whose first segment contains a dot never gets a locale normalized onto it, and lands directly on the
app/[lang]/...routes withlangset to something that is not a locale.For the docs route that is not a 404 — it is an unhandled exception:
Server log:
Thrown out of the Fumadocs page-tree lookup, which is handed a language it has no tree for.
Reproduction
Confirmed on a clean
origin/mainworktree atee74379, so it predates any of the current locale branches.Why it is worth fixing
/foo.bar/privacyand/foo.bar/termsboth render a full page under a bogus locale. Those are unlocalized pages, so they render, but they are being served at a URL that should not resolve at all.Possible directions (not adjudicated)
Direction 1 addresses the cause; direction 2 is a backstop for anything that reaches the routes with a bad segment by another path. They are not mutually exclusive.
Filed unassigned for triage.
Generated by Claude Code