fix(marketing): stop routing one-character paths to the profile page

Merged
SA
2mo ago
+49-6

Problem

Scanners walk short paths. A request to /7 (also /1 … /6) reaches the public profile route, and the render 500s.

User-visible harm: every one-character path on the marketing site returns a 500 error page instead of a 404 — to the scanner that asked, and to any person or crawler that follows such a link. 583 events / 44 users on the fingerprint, arriving in bursts of ~16 (each path fires twice: once in generateMetadata, once in the page render). Traffic is automated and will not stop on its own.

Will the change reach it? Yes. The affected build is the current marketing production release (7d1ee1026, all of today's events); the code path is proxy.ts → [lang]/user/[handle]/page.tsx → loadProfile → leaderboard.public.participant. This change ships in the next marketing deploy on merge, on the same route, and reaches every visitor of that site.

Why code and not an archive / a filter: the issue is not "an error appears in Sentry" — the page really does return 500 for a class of URL that has a correct answer (404). A Sentry-side suppression is forbidden by repo law (PR #6164) and would leave the 500s. The route is not being deleted or restructured by other in-flight work.

Root cause

Two encodings of "what a handle is" live in packages/trpc/src/router/leaderboard/, and they disagreed on the minimum length:

  • HANDLE_PATTERN / isProfileHandle in reserved-handles.ts — {0,38} after the leading character, so 1 to 39 characters. This is the matcher the marketing proxy imports to decide whether a bare path is a profile.
  • handleSchema in schema.ts — .min(2).max(39), so 2 to 39 characters. This is what the API enforces on leaderboard.public.participant.

So isProfileHandle("7") said yes, the proxy rewrote /7 to /en/user/7, the page passed "7" to the API as a handle, and the API refused it with too_small: expected string to have >=2 characters. loadProfile maps only NOT_FOUND to missing and TOO_MANY_REQUESTS to the retry page (PR #7281), and rethrows everything else — correctly, so ISR keeps the stale page on a genuine failure. The throw was unhandled and the route 500'd.

The two files even carried byte-identical copies of the shape regex, with the length rule expressed only in one of them. That is the drift.

Fix

Fix the matcher, in the package that owns the schema — not the page.

  • HANDLE_PATTERN becomes {1,38}, i.e. 2 to 39 characters, matching what handleSchema already enforced.
  • handleSchema now uses HANDLE_PATTERN instead of its own duplicate literal, so the shape exists once.

/7 no longer matches, so it is never rewritten to the profile route and never becomes an API request — it falls through to the proxy's catch-all rewrite and 404s, like any other unmatched path. /user/7 still 308s to /7 first, then 404s.

Why here and not in loadProfile: mapping a BAD_REQUEST to "missing" in the page would silence every input-validation rejection from that procedure — a malformed period, a field added later — not just the handle, and it would still spend an API round trip on each probe. It also puts a second opinion about handles in the page, which is how these drift. Fixing the matcher keeps one owner of the handle rules and stops the request before it is made. loadProfile is unchanged: a genuine server failure still throws, so ISR keeps the stale page.

No typed cause added. Nothing reads one: the rejection no longer happens, and the plain TRPCError from zod input validation is unchanged.

What the narrowed matcher will now reject that it should not: only a genuine one-character handle. None can exist — handleSchema (.min(2)) has gated leaderboard.join, the only writer of handles.handle, since the leaderboard shipped (01ba9ca9c). The tightened pattern also narrows the existing route-collision test's filter, which is correct: a segment the proxy no longer treats as a handle cannot collide with one.

Negative test, written first. reserved-handles.test.ts gains a one-directional invariant — the matcher must never admit a segment handleSchema rejects — over a corpus including single characters, leading/trailing/doubled dashes, reserved words, locales, 39 and 40 characters, dots and underscores. Before the change it failed with ["1", "a", "z", "7"]; after, it passes. It deliberately asserts only the direction that matters (matcher ⊆ schema), so it cannot be satisfied by widening either side.

Verification

bunx biome check on the three changed files:

Checked 3 files in 5ms. No fixes applied.

cd packages/trpc && bun run typecheck:

$ tsc --noEmit --emitDeclarationOnly false

cd apps/marketing && bun run typecheck:

$ tsc --noEmit

The new tests before the change (bun test src/router/leaderboard/reserved-handles.test.ts):

+ [
+   "1",
+   "a",
+   "z",
+ ]
- Expected  - 1
+ Received  + 5
(fail) isProfileHandle > never admits a segment handleSchema rejects [0.60ms]

error: expect(received).toBe(expected)
Expected: false
Received: true
(fail) isProfileHandle > a single-character path is not a handle [0.05ms]

 6 pass
 2 fail

cd packages/trpc && bun test src/router/leaderboard after:

 120 pass
 0 fail
 378 expect() calls
Ran 120 tests across 7 files. [41.00ms]

cd packages/trpc && bun test (full package):

 236 pass
 4 fail
 4 errors
Ran 240 tests across 25 files. [76.00ms]

The 4 failures are pre-existing and unrelated. Reverting only this diff and re-running the same full suite gives the identical set — src/lib/growth/{sitemap,search-console,github}.test.ts (they throw Invalid environment variables at import, and import nothing from leaderboard/) plus src/router/plugins/oauth.test.ts, which passes on its own (12 pass, 0 fail) and fails only under the shared-process run:

src/lib/growth/sitemap.test.ts:
src/lib/growth/search-console.test.ts:
src/lib/growth/github.test.ts:
src/router/plugins/oauth.test.ts:
 234 pass
 4 fail

cd apps/marketing && bun test (full package):

 139 pass
 0 fail
 1007 expect() calls
Ran 139 tests across 12 files. [173.00ms]

No user-facing strings changed, so no catalog work; no desktop git code touched.

Adjacent, named and left alone: opengraph-image.tsx and the fight page also pass a URL segment to fetchParticipant; both are now protected by the same matcher for the paths that reach them, and neither is changed here. There is no root app/not-found.tsx, so an unmatched path renders Next's default 404 rather than the branded [lang]/not-found.tsx — pre-existing for every unmatched path (/foo/bar today), not introduced by this change. MARKETING-6D/6E/6F were the data-cache shape problem #7281 fixed and are untouched.

Refs MARKETING-6C

https://claude.ai/code/session_01LerdAmwQix9L2kLSpqjGPJ


Summary by cubic

Fixes one-character paths like /7 returning 500 errors on the marketing site; they now 404 like any other unmatched path.

The profile-handle matcher accepted 1–39 characters while handleSchema required 2–39, so a single-character path was routed to the profile page, rejected by the API, and the render threw an unhandled error.

  • Tightens HANDLE_PATTERN to match what handleSchema already enforced.
  • Makes handleSchema import the shared HANDLE_PATTERN instead of its own duplicate regex, so the two cannot drift again.
  • Adds a test asserting the matcher never admits a segment the schema rejects.

Summary by CodeRabbit

  • Bug Fixes
    • Profile handles must now contain at least two characters.
    • Single-character paths are no longer treated as profile handles.
    • Handle validation is consistently applied across profile routing and input validation.