Skip to content

Add trailing slash normalisation in host mode#5564

Open
backspace wants to merge 9 commits into
mainfrom
trailing-slash-cs-12249
Open

Add trailing slash normalisation in host mode#5564
backspace wants to merge 9 commits into
mainfrom
trailing-slash-cs-12249

Conversation

@backspace

@backspace backspace commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

When a host mode route map exists, a request for /pricing/ should be treated as one for /pricing. With this running locally:

s 2026-07-21 at 15 25 02@2x

If a route map has a /pricing/ route, it should act as /pricing; the editor now warns about this. I also updated the leading slash to match the font of the field, because the mismatched /es were irksome.

Before s 2026-07-21 at 15 06 31@2x
After s 2026-07-21 at 15 01 02@2x

I chose HTTP 308 according to this flow chart.

backspace and others added 2 commits July 21, 2026 08:20
A host routing rule declared as "/pricing" only rendered for the
trailing-slash form. The bare form 404'd for generic Accept headers
(crawlers, link-unfurlers, curl): serve-index fell through to the module
resolver when the path was not itself an indexed card instance, without
first consulting the routing map. The trailing-slash form took the
directory-index branch and reached the map, so it rendered.

- Consult the routing map before falling through to the module resolver,
  so a bare routed sub-path whose target instance lives elsewhere still
  serves.
- Canonicalize routed paths ahead of conditional-GET handling: redirect
  any non-canonical form (e.g. "/pricing/") to the realm-mount pathname
  joined with the rule's declared path via a 308, so bookmarks, shared
  links and crawlers converge on one URL and relative links in the served
  HTML resolve against a stable document base.
- Add matchHostRoutingRule as the shared, trailing-slash-insensitive
  matcher, and a published-realm regression test covering both forms plus
  the redirect for a rule whose instance lives in a subdirectory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A rule authored as "/pricing/" never matched: request paths are compared
slash-insensitively (RealmPaths.local strips trailing slashes) while the
rule string kept its slash, so only the special-cased "/" root rule ever
matched with a trailing slash. Strip trailing slashes from each rule path
when building the routing map (preserving "/"), so an authored "/pricing/"
behaves identically to "/pricing" for the server match, the canonical
redirect, and the client-side routing map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   2h 55m 30s ⏱️
3 563 tests 3 548 ✅ 15 💤 0 ❌
3 582 runs  3 567 ✅ 15 💤 0 ❌

Results for commit ac48cd7.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   12m 13s ⏱️ +13s
1 928 tests +7  1 928 ✅ +7  0 💤 ±0  0 ❌ ±0 
2 007 runs  +7  2 007 ✅ +7  0 💤 ±0  0 ❌ ±0 

Results for commit 1e8eb6e. ± Comparison against earlier commit ac48cd7.

backspace and others added 2 commits July 21, 2026 14:08
The routing rule path editor renders a fixed "/" accessory in front of a
monospace input. The accessory kept the default sans font, so the slash
read as a separate label rather than the first character of the path.
Give the accessory the same monospace family as the input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A rule path is matched with its trailing slash stripped, so "/pricing/"
behaves identically to "/pricing". The editor now surfaces that through
the existing path advisory rather than silently normalizing the input:
it shows the normalized form the route will match. The realm root "/" is
exempt. Covered by the shared validateRoutingPath tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@backspace
backspace marked this pull request as ready for review July 21, 2026 20:31

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d79ce030ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/runtime-common/realm.ts Outdated
Comment thread packages/realm-server/handlers/serve-index.ts Outdated
backspace and others added 2 commits July 21, 2026 16:30
Normalizing a rule path collapses "/pricing" and "/pricing/" to the same
route, and the map resolves via .find(), so a second colliding rule's
target was silently unreachable — but duplicate detection compared raw
trimmed strings and never flagged it, leaving only the generic
trailing-slash advisory.

Extract the canonical normalization into normalizeRoutingPath and share it
across the map builder, the editor's duplicate detection, and the path
advisory, so a "/pricing" + "/pricing/" pair is reported as a duplicate
route (in normalized form) and the editor's collision banner fires.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The canonical 308 redirect ran before the public-permission gate, so an
unauthenticated request to a non-canonical form (e.g. /pricing/) on a
non-public realm got a route-specific redirect — disclosing that the
private routed path exists. Previously non-public HTML requests reached the
generic Boxel shell without their routing configuration being consulted.

Move the routing-map lookup and the canonical redirect after the
public-permission check, so a non-public realm falls through to the generic
shell before any route is revealed. The ETag/304 fast-path stays ahead of
the permission check — it discloses nothing about routes — so cached
revalidation remains cheap. Adds a regression test asserting a non-public
realm emits no route-specific redirect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@backspace
backspace requested a review from a team July 22, 2026 15:16

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Reviewed with a focus on the request-routing state machine in serve-index.ts — specifically how the new bare-path branch interacts with the permission gate, the ETag fast-path, and the trailing-slash canonicalization — plus the shared normalization contract across the server map builder, the editor validators, and the client resolver.

Bottom line: the trailing-slash normalization and canonical-redirect work is solid and well-tested, but the new */* bare-path gate reintroduces the exact private-route disclosure this PR otherwise took care to close — on a non-public realm it now answers 200 for a real route and 404 for a non-route. That's the one issue I'd resolve before merge; everything else is follow-up or cleanup.

What lands right

  • Moving the routing-map lookup + 308 behind hasPublicPermissions (while keeping the ETag/304 fast-path ahead of it, since a revalidation discloses nothing) is the correct ordering, and the reasoning is captured in the comment at lines 361-367. My one caveat is that the bare-path branch doesn't yet honor that same ordering — see the inline thread.
  • Centralizing trailing-slash handling in normalizeRoutingPath and reusing it across getHostRoutingMap, findDuplicateRoutingPaths, and validateRoutingPath is the right shape: the editor's collision banner, the path advisory, and the server match now agree by construction. The .find()-makes-the-second-target-unreachable case the earlier bot review raised is genuinely handled by the duplicate detection now.
  • matchHostRoutingRule guarding RealmPaths.local against its status:404 throw is the right instinct — I only wish the text/html path reused it rather than re-implementing the match inline (inline thread).
  • Choosing 308 (not 302) so the method/body are preserved and the canonical form is treated as permanent is correct for this use.

Recommendations (detail in the inline threads)

  1. Gate the bare-path serve on public permissions so a non-public realm can't be probed via 200-vs-404. — serve-index.ts, the !isCardInstance block. Should-fix.
  2. Add the negative-space test for bare + */* on a non-public realm; the existing disclosure test only covers text/html + trailing slash, which never hits the leaky branch. — index-responses-test.ts.
  3. Consolidate the canonicalizationmatchHostRoutingRule returns a full match (rule + canonicalPathname) that the caller uses only as a boolean while the HTML path recomputes it; unify so the formula and the local() guard live once. — serve-index.ts. Follow-up.
  4. Reconcile the "single source of truth" claim with the client's pre-existing canonicalizeRoutingPath twin in host-mode-service.ts. — host-routing-validation.ts. Follow-up.

Adjacent, out of scope

  • The Host Memory Baseline check is red while Host and Realm test suites are green; it reads as the standalone baseline job rather than anything in this diff, but worth a glance before merge.
  • getHostRoutingMap normalizes an empty ('') authored path to /, so a hand-edited realm.json rule with a blank path + a linked instance would become a realm-root rule. This actually aligns the server with the editor (which maps an unset path to /) and the client, so it's arguably a latent fix rather than a bug — noting it only so the empty→root behavior is a conscious choice.

None of these block from my side except at your discretion on #1; submitting as a comment so approve/request-changes stays with a human reviewer.


Generated by Claude Code

Comment thread packages/realm-server/handlers/serve-index.ts
Comment thread packages/realm-server/handlers/serve-index.ts Outdated
Comment thread packages/runtime-common/host-routing-validation.ts Outdated
backspace and others added 3 commits July 22, 2026 11:09
The bare-path branch (non-text/html, e.g. */* from crawlers) consulted the
routing map before any permission check, so on a non-public realm a real
route fell through to the generic 200 shell while a non-route 404'd —
letting an unauthenticated caller enumerate configured routes by diffing
200 vs 404. This is the same disclosure class the redirect reorder closed,
on a different signal.

Require public read before honoring the matched rule, so routed and
non-routed bare paths behave identically (both next() → 404) on a
non-public realm. Adds the negative-space test: a routed and a non-routed
bare path return the same status for a generic Accept header on a
non-public realm (the existing text/html + trailing-slash test never
exercised this branch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The HTML path re-derived the match and canonical form inline — re-reading
the routing map, re-running the find, and recomputing canonicalPathname
with the same formula that already lives in matchHostRoutingRule — so the
canonicalization had two homes that could drift, and only the helper's copy
guarded RealmPaths.local against its in-realm throw.

Have the HTML path call matchHostRoutingRule and use its rule +
canonicalPathname for the redirect and cardURL rewrite, so the match and
canonical formula exist once. The full map is still fetched here for the
config meta-tag injection. Behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`HostModeService` had its own `canonicalizeRoutingPath` twin doing the same
job as `normalizeRoutingPath`, so the docstring's "single source of truth"
claim wasn't backed by the tree, and the two could drift (they already
differed on the unreachable `//` edge). Have `resolveRoutedPath` import and
use `normalizeRoutingPath`, delete the twin, and update the docstring to
name the client resolver as a consumer — now the claim holds. Map keys
arrive server-normalized and `resolveRoutedPath`'s input is always
slash-prefixed, so behavior is unchanged for every reachable path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants