Repository navigation
Return 404 for calculator paths whose first segment is not a country - #1365
Merged
Merged
Conversation
app.policyengine.org answered any first path segment with a 200 HTML calculator shell (x-matched-path: /[countryId]) because the country layout was a client component that rendered nothing and redirected to / in the browser. /llms.txt, /favicon.ico, /US and /xx/reports all looked like real pages to crawlers and monitors. The layout is now a server component that calls notFound() unless the segment is a supported country (isCountryId, exact match against countryIds), so those paths are HTTP 404s. The client providers move to CountryShell unchanged. A calculator not-found page replaces Next's default, with a "Page not found" title. Static files, robots.txt, /api routes and every supported country keep their status: Next serves those before the [countryId] route, and a local production build answers 43 paths identically to main except the 13 unknown-first-segment ones, which go from 200 to 404. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…tils Review nits on #1365: the 404 page's helper text now uses colors.text.secondary and its heading typography.lineHeight.none instead of gray[500] and a literal 1. NotFound.test.tsx renders through @Test-Utils. CountryLayout.test.tsx keeps @testing-library/react, with a comment saying why: @Test-Utils wraps its own CountryProvider('us'), which would hide a layout that failed to provide the country. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
marked this pull request as ready for review
October 20, 2026 20:47
Contributor
Author
|
Merging under the merge gates in Max's agent instructions. The only open block is the ruleset's required approving review. This PR is under Max's account, so it can't approve itself, so I'm admin-merging pinned to
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
app.policyengine.orgreturns a 200 HTML calculator page for any first path segment. Production, 2026-10-20:x-matched-path/llms.txttext/html/[countryId]/favicon.icotext/html/[countryId]/UStext/html/[countryId]/xxtext/html/[countryId]calculator-app/src/app/[countryId]/layout.tsxwas a client component. For an unsupported country it renderednulland calledrouter.replace("/")in the browser. The server had already sent a 200 shell by then. These soft 200s hide real 404s from crawlers and monitoring, and they make the calculator answer for paths it doesn't own. One was found while portingllms.txtto the website in #1332.Change
[countryId]/layout.tsxis now a server component, like the website's. It awaitsparamsand callsnotFound()unlessisCountryId(countryId).[countryId]/CountryShell.tsx(new,"use client") holds the Country, Navigation and Location providers andCalculatorProviders, moved over unchanged.app/src/libs/countries.tsaddsisCountryId(value: unknown): value is CountryId. It is an exact match againstcountryIds, soUSand" us"are not countries.calculator-app/src/app/not-found.tsx(new) is the calculator's 404 page. It has a "Page not found | PolicyEngine calculator" title and a link back to/, styled likeerror.tsx. Next adds<meta name="robots" content="noindex">to it.ARCHITECTURE-COUNTRY-ID.mdnow describes the 404 behaviour.fast-checkis added toappdevDependencies at the same^4.10.2the website already uses. The lockfile diff is that one line.Review nits (round 2): the 404 page uses
colors.text.secondaryandtypography.lineHeight.none, andNotFound.test.tsxrenders through@test-utils.CountryLayout.test.tsxkeeps@testing-library/reacton purpose:@test-utilswraps aCountryProvider('us')of its own, which would hide a layout that failed to provide the country.The supported list stays
countryIds(us,uk,ca,ng,il). If #1345 removes countries, the 404 follows that list automatically.Nothing legitimate moves
Next serves
public/files (calculator-app/public→app/public), metadata routes (/robots.txt) and static route folders (/api/*) before the dynamic[countryId]route, so the layout never sees those paths.Route table.
next buildgives the same rendering mode for every route onmainand on this branch: every/[countryId]/*route is stillƒ(rendered on demand), and/and/robots.txtare still static. The only difference is that/_not-foundis now the custom page.Local production builds (
next start) ofmainand of this branch were each sent 43 paths. 30 return the same status on both. The 13 that changed all went from 200 to 404, and every one of them has a first segment that isn't a supported country:/200 ·/us/uk/ca/ng/il200 ·/us/reports/uk/policies/us/build/us/ask/us/account/us/households/us/simulations/create/us/report-output/abc/us/report-output/abc/config/us/report/123/us/callback200 ·/us/no-such-page404 ·/robots.txt200 ·/favicon.svg/assets/logos/policyengine/teal.svg/assets/logos/policyengine/teal-square.png200 ·/api/geolocate307 (rewrite) ·/api/calibration-targets/api/model-validation400 ·/api/scorecard-comparisons405 ·/api/does-not-exist/.well-known/security.txt/data/geojson/_next/static/does-not-exist.js404/llms.txt/llms-full.txt/favicon.ico/sitemap.xml/wp-login.php/US/Uk/usa/gb/xx/%20us/xx/reports/llms.txt/reportsInvariants
s, the layout renders the calculator ifs ∈ countryIds. Otherwise it callsnotFound()exactly once and renders nothing. This is a fast-check property over ASCII and full-Unicode strings, country ids with padding around them, and upper-cased ids. Example tests cover each country, and real crawler paths such asllms.txt,favicon.icoandwp-login.php.isCountryIdmatches exactly. For any JS value (fc.anything()),isCountryId(v)impliesv ∈ countryIds. A country id with any non-empty padding is never a country id. Upper case never matches.calculator-app/publicorcalculator-app/src/appis named exactly after a country. Next serves those before[countryId], so one would shadow that country's calculator. A test also checks thatcalculator-app/publicstill resolves toapp/public.Mutation check. Each mutant was swapped in, the suite was run, and the fix was restored:
notFound()call: 14 tests fail. Every example test fails with "promise resolved instead of rejecting", and the property fails withexpected 'rendered' to be 'not found'.["US"].main's client layout: 19 tests fail. The calculator-shell tests fail withTypeErrorbecause a client component can't run as a server layout, so this one is a structural catch rather than a behavioural one.Checks run locally
next build(calculator): passes.ScorecardComparisons.test.tsx, which this PR doesn't touch. They fail the same way on untouchedmainunder local Node 26 (£1Bvs£1.0B), and Pin the scorecard's compact money format and run CI tests on Node 26 #1363 fixes them. CI's runner Node passes them.Preview verification
The same 43 paths were curled on the
policyengine-calculator-nextpreview of 19b1022, through the project's existing automation-bypass key, and on production:x-matched-pathis still/[countryId], but the response is now the 404 page: title "Page not found | PolicyEngine calculator", with<meta name="robots" content="noindex">./api/geolocatebelow. That covers every country, the deep calculator routes, assets,/robots.txtand the/api/*handlers./api/geolocateis 307 on the preview and 200 in production. This predates the PR: Pin the scorecard's compact money format and run CI tests on Node 26 #1363's preview, which doesn't have this change, 307s the same way, tohttps://www.policyengine.org/api/geolocate. Previews proxy to the apexpolicyengine.org, which redirects to www, while production points straight at a host that answers 200.Re-check at head b060a4b (after the review nits and a merge of
main): the 43 paths were curled again on its preview against production. The same 13 paths go from 200 to 404,/api/geolocatestill shows the preview-only 307, and every other path is unchanged.Independent review
GPT-6.1 Sol reviewed the PR in two rounds, and both returned
VERDICT: APPROVE:notFound()in this layout sets a 404 status, because there is no Suspense boundary above it. It also confirmed that the rootnot-found.tsxand its metadata apply.main. It agreed thatCountryLayout.test.tsxshould keep@testing-library/react.Sol's lane had no shell, so I ran the checks it couldn't: the merge's diff of
app/package.jsonandbun.lockagainstmainis only thefast-checkline, the calculator tests pass, and calculator Prettier and ESLint pass. The FlagshipGate soft 404 that Sol raised can't be reached in production, because the US flagship flag is on unless set to"false".axiom: n/a: calculator routing (UI infrastructure, no policy logic)
🤖 Generated with Claude Code