From f0bed5774dd9ca8ac1e42903c7fb64decdca429f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 21 Jul 2026 08:20:01 -0500 Subject: [PATCH 1/9] Serve and canonicalize host-routed paths without a trailing slash 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) --- packages/realm-server/handlers/serve-index.ts | 86 ++++-- packages/realm-server/lib/realm-routing.ts | 54 ++++ .../server-endpoints/index-responses-test.ts | 267 ++++++++++++++++++ 3 files changed, 386 insertions(+), 21 deletions(-) diff --git a/packages/realm-server/handlers/serve-index.ts b/packages/realm-server/handlers/serve-index.ts index a484baeb93e..2cb885a966e 100644 --- a/packages/realm-server/handlers/serve-index.ts +++ b/packages/realm-server/handlers/serve-index.ts @@ -25,6 +25,7 @@ import { getPublishedRealmInfo, hasPublicPermissions, isIndexedCardInstance, + matchHostRoutingRule, type RealmRoutingDeps, } from '../lib/realm-routing.ts'; import type { RealmRegistryReconciler } from '../lib/realm-registry-reconciler.ts'; @@ -248,7 +249,16 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers { let cardURL = requestURL; let isCardInstance = await isIndexedCardInstance(cardURL, routingDeps); if (!isCardInstance) { - return next(); + // A bare routed sub-path (e.g. /pricing) is not itself an indexed + // card instance — its host routing rule maps it to a card that + // lives elsewhere (e.g. /pages/pricing). Only fall through to the + // module resolver (→ 404) when the path also matches no routing + // rule. Without this, the bare form 404s while the trailing-slash + // form — which skips this gate via isIndexRequest — renders. + let matchedRule = await matchHostRoutingRule(requestURL, routingDeps); + if (!matchedRule) { + return next(); + } } } } @@ -292,6 +302,45 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers { ctxt.type = 'html'; + // Resolve the realm once and reuse it for canonicalization, the + // permissions check, and the routing-map lookup below. `findOrMountRealm` + // can fall back to a DB probe when the in-memory registry is cold, so we + // don't want to pay that cost more than necessary on the hot HTML path. + let routedRealm = await findOrMountRealm(requestURL, routingDeps); + + // Canonicalize routed paths BEFORE conditional-GET handling. + // A host routing rule declared as '/pricing' matches both '/pricing' and + // '/pricing/' (RealmPaths.local strips trailing slashes). The canonical + // form is the realm mount pathname joined with the rule's declared path — + // so the realm-root rule '/' keeps its trailing slash while a sub-path + // rule has none. Redirect any other form with a 308 so bookmarks, shared + // links and crawlers converge on one URL, and so relative links in the + // served HTML resolve against a stable document base (a trailing slash + // shifts that base and breaks './'-relative hrefs in the prerendered + // page). This runs ahead of the ETag/304 block below so a repeat request + // under a non-canonical form still redirects rather than 304-ing. + let routingMap: { path: string; id: string }[] = []; + if (routedRealm) { + routingMap = await routedRealm.getHostRoutingMap(); + if (routingMap.length > 0) { + let realmURL = new URL(routedRealm.url); + realmURL.protocol = requestURL.protocol; + let pathInRealm = '/' + new RealmPaths(realmURL).local(requestURL); + let rule = routingMap.find((r) => r.path === pathInRealm); + if (rule) { + let canonicalPathname = + realmURL.pathname + rule.path.replace(/^\//, ''); + if (requestURL.pathname !== canonicalPathname) { + ctxt.redirect( + new URL(canonicalPathname + requestURL.search, requestURL).href, + ); + ctxt.status = 308; + return; + } + } + } + } + let cardURL = requestURL; let isIndexRequest = requestURL.pathname.endsWith('/'); if (isIndexRequest) { @@ -329,11 +378,6 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers { return; } } - // Resolve the realm once and reuse for both the permissions check and - // the routing-map lookup below. `findOrMountRealm` can fall back to a - // DB probe when the in-memory registry is cold, so we don't want to - // pay that cost twice on the hot HTML path. - let routedRealm = await findOrMountRealm(requestURL, routingDeps); let publicPermissions = await hasPublicPermissions( routedRealm, routingDeps, @@ -350,21 +394,21 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers { // CS-10055: host routing rules in the realm config can map a bare path // (e.g. /whitepaper) to a target card. When the requested path matches // a rule, rewrite cardURL so the head/isolated/scoped CSS fetched - // below render the routed target. The same map is also written into - // the @cardstack/host/config/environment meta tag further down so the - // SPA can resolve the path post-hydration. - let routingMap: { path: string; id: string }[] = []; - if (routedRealm) { - routingMap = await routedRealm.getHostRoutingMap(); - if (routingMap.length > 0) { - let realmURL = new URL(routedRealm.url); - realmURL.protocol = requestURL.protocol; - let realmPaths = new RealmPaths(realmURL); - let pathInRealm = '/' + realmPaths.local(requestURL); - let rule = routingMap.find((r) => r.path === pathInRealm); - if (rule) { - cardURL = new URL(rule.id); - } + // below render the routed target. The same map (resolved above for + // canonicalization) is also written into the + // @cardstack/host/config/environment meta tag further down so the SPA + // can resolve the path post-hydration. + if (routedRealm && routingMap.length > 0) { + let realmURL = new URL(routedRealm.url); + realmURL.protocol = requestURL.protocol; + let realmPaths = new RealmPaths(realmURL); + let pathInRealm = '/' + realmPaths.local(requestURL); + let rule = routingMap.find((r) => r.path === pathInRealm); + if (rule) { + // The request has already been canonicalized above, so `rule` + // matches the canonical form here; rewrite cardURL so the + // head/isolated/scoped CSS fetched below render the routed target. + cardURL = new URL(rule.id); } } diff --git a/packages/realm-server/lib/realm-routing.ts b/packages/realm-server/lib/realm-routing.ts index a9848654b4f..90f6bb54e33 100644 --- a/packages/realm-server/lib/realm-routing.ts +++ b/packages/realm-server/lib/realm-routing.ts @@ -93,6 +93,60 @@ export async function findOrMountRealm( return await reconciler.lookupOrMount(rows[0].url); } +// A host routing rule matched against a concrete request URL, together +// with the realm it belongs to and that realm's canonical form of the +// path. `canonicalPathname` is the single URL the rule should be served +// under: the realm mount pathname joined with the rule's declared path +// (so the realm-root rule '/' keeps its trailing slash, while a sub-path +// rule like '/pricing' has none). Callers redirect to it when the request +// arrived under a different form. +export type MatchedHostRoutingRule = { + realm: Realm; + rule: { path: string; id: string }; + canonicalPathname: string; +}; + +// Resolve a request URL against the routed realm's host routing rules. +// Trailing-slash-insensitive: `RealmPaths.local` strips trailing slashes, +// so '/pricing' and '/pricing/' both match a rule declared as '/pricing'. +// Returns undefined when the realm has no routing rules or the path +// matches none. Used both to decide whether a bare routed path is +// servable (rather than falling through to the module resolver) and to +// canonicalize the request URL. +export async function matchHostRoutingRule( + requestURL: URL, + deps: RealmRoutingDeps, +): Promise { + let realm = await findOrMountRealm(requestURL, deps); + if (!realm) { + return undefined; + } + let routingMap = await realm.getHostRoutingMap(); + if (routingMap.length === 0) { + return undefined; + } + let realmURL = new URL(realm.url); + realmURL.protocol = requestURL.protocol; + let realmPaths = new RealmPaths(realmURL); + let pathInRealm: string; + try { + pathInRealm = '/' + realmPaths.local(requestURL); + } catch { + // local() throws when the request is not within the realm; treat as + // no match rather than surfacing the error on the hot HTML path. + return undefined; + } + let rule = routingMap.find((r) => r.path === pathInRealm); + if (!rule) { + return undefined; + } + return { + realm, + rule, + canonicalPathname: realmURL.pathname + rule.path.replace(/^\//, ''), + }; +} + export async function getPublishedRealmInfo( requestURL: URL, deps: RealmRoutingDeps, diff --git a/packages/realm-server/tests/server-endpoints/index-responses-test.ts b/packages/realm-server/tests/server-endpoints/index-responses-test.ts index 8d35e5e9df0..8e9e4d20bc4 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -1605,4 +1605,271 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { }); }, ); + + // Host routing rules must resolve a bare sub-path (e.g. /pricing) as well + // as the trailing-slash form, and canonicalize between them. The bug: the + // bare form 404'd for generic (non-text/html) Accept + // headers — crawlers, link-unfurlers, curl — because serve-index bailed to + // the module resolver when the path wasn't itself an indexed card + // instance, without first consulting the routing map. The trailing-slash + // form took the directory-index branch and rendered. This module publishes + // a realm whose routed instance lives in a subdirectory (its id therefore + // does NOT equal the routed path, so the card-id fallback cannot mask the + // bug) and asserts both forms plus the 308 canonical redirect. + module( + 'Published realm: host routing rules + trailing-slash canonicalization', + function (hooks) { + let testRealmHttpServer: Server; + let testRealmServer: Awaited< + ReturnType + >['testRealmServer']; + let request: SuperTest; + let dbAdapter: PgAdapter; + let dir: DirResult; + let sourceRealmUrlString: string; + let publishedRealmURLString: string; + let publishedRealmHost: string; + let publishedRealmPath: string; + let ownerUserId = '@mango:localhost'; + + hooks.beforeEach(function () { + dir = dirSync(); + }); + setupDB(hooks, { + beforeEach: async (_dbAdapter, _publisher, _runner) => { + dbAdapter = _dbAdapter; + let virtualNetwork = createVirtualNetwork(); + let testRealmDir = join(dir.name, 'realm_server_routing', 'test'); + ensureDirSync(testRealmDir); + ({ testRealmHttpServer, testRealmServer } = await runTestRealmServer({ + virtualNetwork, + testRealmDir, + fileSystem: {}, + realmsRootPath: join(dir.name, 'realm_server_routing'), + realmURL: new URL('http://127.0.0.1:4444/test/'), + dbAdapter: _dbAdapter, + publisher: _publisher, + runner: _runner, + matrixURL, + permissions: { + '*': ['read', 'write'], + [ownerUserId]: DEFAULT_PERMISSIONS, + }, + domainsForPublishedRealms: { + boxelSpace: 'localhost', + boxelSite: 'localhost:4444', + }, + })); + request = supertest(testRealmHttpServer); + + // Create a publishable source realm. + let endpoint = 'routing-source'; + let createResponse = await request + .post('/_create-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: ownerUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send( + JSON.stringify({ + data: { + type: 'realm', + attributes: { name: 'Routing Source Realm', endpoint }, + }, + }), + ); + if (createResponse.status !== 202) { + throw new Error( + `/_create-realm failed with status ${createResponse.status}: ` + + (createResponse.text || + (createResponse.body + ? JSON.stringify(createResponse.body) + : '')), + ); + } + + sourceRealmUrlString = createResponse.body.data.id; + let sourceRealmPath = new URL(sourceRealmUrlString).pathname; + + // Make the source realm publicly readable. + await _dbAdapter.execute(` + INSERT INTO realm_user_permissions (realm_url, username, read, write, realm_owner) + VALUES ('${sourceRealmUrlString}', '*', true, true, true) + `); + + // The routed instance lives in a subdirectory. Its id is + // /pages/pricing, which is deliberately NOT the routed path + // (/pricing) — so a 200 on /pricing can only come from the + // routing map, never from the card-id fallback. + let instanceResponse = await request + .post(`${sourceRealmPath}pages/pricing.json`) + .set('Accept', 'application/vnd.card+source') + .send( + JSON.stringify({ + data: { + type: 'card', + id: `${sourceRealmUrlString}pages/pricing`, + attributes: { cardInfo: { name: 'Pricing' } }, + meta: { + adoptsFrom: { + module: '@cardstack/base/card-api', + name: 'CardDef', + }, + }, + }, + }), + ); + if (instanceResponse.status !== 204) { + throw new Error( + `Failed to write pages/pricing: ${instanceResponse.status} ${instanceResponse.text}`, + ); + } + + // Overwrite realm.json with a routing rule mapping the bare + // sub-path /pricing to the subdirectory instance. Writing realm.json + // re-indexes the RealmConfig card, so the routing map picks the rule + // up (and, after publish, the published realm's own index does too). + let realmConfigResponse = await request + .post(`${sourceRealmPath}realm.json`) + .set('Accept', 'application/vnd.card+source') + .send( + JSON.stringify({ + data: { + type: 'card', + attributes: { + cardInfo: { name: 'Routing Source Realm' }, + hostRoutingRules: [{ path: '/' }, { path: '/pricing' }], + }, + relationships: { + 'hostRoutingRules.0.instance': { + links: { self: './index' }, + }, + 'hostRoutingRules.1.instance': { + links: { self: './pages/pricing' }, + }, + }, + meta: { + adoptsFrom: { + module: '@cardstack/base/realm-config', + name: 'RealmConfig', + }, + }, + }, + }), + ); + if (realmConfigResponse.status !== 204) { + throw new Error( + `Failed to write realm.json: ${realmConfigResponse.status} ${realmConfigResponse.text}`, + ); + } + + // Publish the source realm — triggers a full from-scratch reindex of + // the published copy. + publishedRealmURLString = + 'http://routingtest.localhost:4444/routing-source/'; + publishedRealmHost = new URL(publishedRealmURLString).host; + publishedRealmPath = new URL(publishedRealmURLString).pathname; + + let publishResponse = await request + .post('/_publish-realm') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: ownerUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`, + ) + .send( + JSON.stringify({ + sourceRealmURL: sourceRealmUrlString, + publishedRealmURL: publishedRealmURLString, + }), + ); + if (publishResponse.status !== 202) { + throw new Error( + `Failed to publish realm: ${publishResponse.status} ${publishResponse.text}`, + ); + } + + await testRealmServer.testingOnlyReconcile(); + let readinessResponse = await request + .get(`${publishedRealmPath}_readiness-check`) + .set('Host', publishedRealmHost) + .set('Accept', 'application/vnd.api+json'); + if (readinessResponse.status !== 200) { + throw new Error( + `Published realm not ready: ${readinessResponse.status} ${readinessResponse.text}`, + ); + } + await settlePrerenderHtmlJobs(dbAdapter, publishedRealmURLString); + }, + afterEach: async () => { + await closeServer(testRealmHttpServer); + }, + }); + + test('bare routed sub-path serves HTML for a generic Accept header (regression: was 404)', async function (assert) { + let response = await request + .get(`${publishedRealmPath}pricing`) + .set('Host', publishedRealmHost) + .set('Accept', '*/*'); + + assert.strictEqual( + response.status, + 200, + `bare /pricing serves for */* (was 404 via the module resolver). body=${response.text?.slice( + 0, + 300, + )}`, + ); + }); + + test('bare routed sub-path serves HTML for text/html', async function (assert) { + let response = await request + .get(`${publishedRealmPath}pricing`) + .set('Host', publishedRealmHost) + .set('Accept', 'text/html'); + + assert.strictEqual(response.status, 200, 'bare /pricing serves HTML'); + }); + + test('trailing-slash form 308-redirects to the canonical bare form', async function (assert) { + let response = await request + .get(`${publishedRealmPath}pricing/`) + .set('Host', publishedRealmHost) + .set('Accept', 'text/html'); + + assert.strictEqual(response.status, 308, '/pricing/ is redirected'); + let location = response.headers['location'] ?? ''; + assert.true( + location.endsWith(`${publishedRealmPath}pricing`), + `redirect strips the trailing slash (location=${location})`, + ); + assert.false( + location.endsWith('/pricing/'), + `redirect target has no trailing slash (location=${location})`, + ); + }); + + test('trailing-slash form is also canonicalized for a generic Accept header', async function (assert) { + let response = await request + .get(`${publishedRealmPath}pricing/`) + .set('Host', publishedRealmHost) + .set('Accept', '*/*'); + + assert.strictEqual( + response.status, + 308, + '/pricing/ is redirected for */* too', + ); + }); + }, + ); }); From c92fbdecc9fae225438cc37573e0fa6349f702cf Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 21 Jul 2026 08:29:24 -0500 Subject: [PATCH 2/9] Normalize trailing slashes in host routing rule paths 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) --- .../realm-server/tests/realm-routing-test.ts | 25 ++++++++++++++----- packages/runtime-common/realm.ts | 12 +++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/realm-server/tests/realm-routing-test.ts b/packages/realm-server/tests/realm-routing-test.ts index a4856d45ed7..c77748c927d 100644 --- a/packages/realm-server/tests/realm-routing-test.ts +++ b/packages/realm-server/tests/realm-routing-test.ts @@ -13,8 +13,9 @@ import { resolveRealmsForFederatedRequest } from '../lib/realm-routing.ts'; // CS-10054: fixture for Realm.getHostRoutingMap coverage. One rule uses // a relative reference (the recommended form, portable across realm URL -// changes) and one is cross-realm — the latter must be dropped by the -// same-realm guard. +// changes), one is cross-realm — dropped by the same-realm guard — and +// one is authored with a trailing slash, which must be normalized to its +// slash-free form so it matches request paths. function makeRoutingFixture(): Record< string, string | LooseSingleCardDocument @@ -48,7 +49,11 @@ function makeRoutingFixture(): Record< // `instance` is a linksTo on RoutingRuleField, so the link // target lives in `relationships` keyed by the field path // (`hostRoutingRules..instance`), not inline in attributes. - hostRoutingRules: [{ path: '/rel' }, { path: '/foreign' }], + hostRoutingRules: [ + { path: '/rel' }, + { path: '/foreign' }, + { path: '/trailing/' }, + ], }, relationships: { 'hostRoutingRules.0.instance': { @@ -62,6 +67,11 @@ function makeRoutingFixture(): Record< 'hostRoutingRules.1.instance': { links: { self: 'http://otherrealm.test/x' }, }, + // Authored with a trailing slash: the map must normalize the + // path to '/trailing' so it matches slash-stripped request paths. + 'hostRoutingRules.2.instance': { + links: { self: './white-paper' }, + }, }, meta: { adoptsFrom: { @@ -92,13 +102,16 @@ module(basename(import.meta.filename), function () { await testRealm.indexing(); }); - test('resolves relative references and drops cross-realm rules', async function (assert) { + test('resolves relative references, drops cross-realm rules, and normalizes trailing slashes', async function (assert) { let map = await testRealm.getHostRoutingMap(); assert.deepEqual( map, - [{ path: '/rel', id: `${realmURL.href}white-paper` }], - 'relative reference resolved against the realm root; cross-realm rule filtered', + [ + { path: '/rel', id: `${realmURL.href}white-paper` }, + { path: '/trailing', id: `${realmURL.href}white-paper` }, + ], + 'relative reference resolved against the realm root; cross-realm rule filtered; trailing-slash rule normalized to /trailing', ); }); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 1290c50ab36..e63215d5510 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -6794,6 +6794,14 @@ export class Realm { let path = (rule as Record).path; let instance = (rule as Record).instance; if (typeof path !== 'string') return []; + // Normalize a rule authored with a trailing slash ('/pricing/') to + // its canonical slash-free form ('/pricing'). Request paths are + // matched slash-insensitively (RealmPaths.local strips trailing + // slashes), so an un-normalized '/pricing/' rule would never match; + // normalizing here also feeds the correct canonical form to the + // serve-index redirect and the client-side routing map. The + // realm-root rule '/' is preserved. + let normalizedPath = path.replace(/\/+$/, '') || '/'; if (!instance || typeof instance !== 'object') return []; let id = (instance as Record).id; if (typeof id !== 'string') return []; @@ -6815,11 +6823,11 @@ export class Realm { // are handled correctly. if (!this.paths.inRealm(idURL)) { this.#log.warn( - `dropping host routing rule for path "${path}" — target ${id} is outside this realm`, + `dropping host routing rule for path "${normalizedPath}" — target ${id} is outside this realm`, ); return []; } - return [{ path, id }]; + return [{ path: normalizedPath, id }]; }); return (this.#cachedHostRoutingMap = map); } catch (e) { From 7f780966b3c3e608feec28f40c8ce356e49f1a88 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 21 Jul 2026 14:08:20 -0500 Subject: [PATCH 3/9] Match the routing-path field's leading "/" to the mono input font 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) --- packages/base/realm-config.gts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/base/realm-config.gts b/packages/base/realm-config.gts index ea3013c225f..26e3abfbd69 100644 --- a/packages/base/realm-config.gts +++ b/packages/base/realm-config.gts @@ -164,6 +164,9 @@ class RoutingRuleEdit extends Component { pierced directly. */ .path-cell :deep(.text-accessory) { padding-right: 0; + /* Match the mono input text so the fixed leading "/" reads as part + of the same path string rather than a separate label. */ + font-family: var(--boxel-font-family-mono, monospace); } .path-cell :deep(.form-control) { padding-left: var(--boxel-sp-xxs); From d79ce030ed80d72d4bcdc3bf38d71d099e45c1fa Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 21 Jul 2026 14:25:09 -0500 Subject: [PATCH 4/9] Advise in the routing editor when a rule path has a trailing slash 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) --- .../tests/host-routing-validation-test.ts | 4 ++++ .../runtime-common/host-routing-validation.ts | 12 +++++++++++ .../tests/host-routing-validation-test.ts | 21 +++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/packages/realm-server/tests/host-routing-validation-test.ts b/packages/realm-server/tests/host-routing-validation-test.ts index 941e3290a35..92b5a155fd6 100644 --- a/packages/realm-server/tests/host-routing-validation-test.ts +++ b/packages/realm-server/tests/host-routing-validation-test.ts @@ -30,6 +30,10 @@ module(basename(import.meta.filename), function () { await runSharedTest(hostRoutingValidationTests, assert, {}); }); + test('validateRoutingPath: advises when the path has a trailing slash', async function (assert) { + await runSharedTest(hostRoutingValidationTests, assert, {}); + }); + test('validateRoutingPath: trims surrounding whitespace before validating', async function (assert) { await runSharedTest(hostRoutingValidationTests, assert, {}); }); diff --git a/packages/runtime-common/host-routing-validation.ts b/packages/runtime-common/host-routing-validation.ts index 4c26b33b59d..0d262a7f71f 100644 --- a/packages/runtime-common/host-routing-validation.ts +++ b/packages/runtime-common/host-routing-validation.ts @@ -15,6 +15,9 @@ const VALID_PATH_PATTERN = /^\/(?:[A-Za-z0-9._~/-]|%[0-9A-Fa-f]{2})*$/; * - Otherwise composed of the unreserved character set * (letters, numbers, `-`, `_`, `.`, `~`), `/` separators, or * percent-encoded `%XX` sequences (`X` is a hex digit). + * - A trailing slash is stripped when the route is matched, so it is + * advised against (with the normalized form shown) rather than + * rejected. The realm root `/` is exempt. */ export function validateRoutingPath( path: string | null | undefined, @@ -28,6 +31,15 @@ export function validateRoutingPath( if (!VALID_PATH_PATTERN.test(trimmed)) { return 'Path may only contain letters, numbers, /, -, _, ., ~, or %XX-encoded characters'; } + // A trailing slash is stripped when the route is matched (see + // Realm.getHostRoutingMap), so '/pricing/' behaves exactly like + // '/pricing'. Surface that instead of silently normalizing the author's + // input. The root '/' is the realm root, not a trailing slash, so it is + // exempt. + if (trimmed !== '/' && trimmed.endsWith('/')) { + let normalized = trimmed.replace(/\/+$/, '') || '/'; + return `Trailing slash is ignored; this route matches "${normalized}"`; + } return undefined; } diff --git a/packages/runtime-common/tests/host-routing-validation-test.ts b/packages/runtime-common/tests/host-routing-validation-test.ts index 26869370550..1212ead931f 100644 --- a/packages/runtime-common/tests/host-routing-validation-test.ts +++ b/packages/runtime-common/tests/host-routing-validation-test.ts @@ -61,6 +61,27 @@ const tests: SharedTests = Object.freeze({ assert.strictEqual(validateRoutingPath('/foo%gg'), INVALID_CHARS_MSG); }, + 'validateRoutingPath: advises when the path has a trailing slash': async ( + assert, + ) => { + assert.strictEqual( + validateRoutingPath('/pricing/'), + 'Trailing slash is ignored; this route matches "/pricing"', + ); + assert.strictEqual( + validateRoutingPath('/blog/posts/'), + 'Trailing slash is ignored; this route matches "/blog/posts"', + ); + // Trimmed before checking, so trailing whitespace after the slash + // still warns and normalizes correctly. + assert.strictEqual( + validateRoutingPath(' /docs/ '), + 'Trailing slash is ignored; this route matches "/docs"', + ); + // The realm root's slash is the root itself, not a trailing slash. + assert.strictEqual(validateRoutingPath('/'), undefined); + }, + 'validateRoutingPath: trims surrounding whitespace before validating': async ( assert, ) => { From aceba4a8919cff764f77490b8681847745679998 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 21 Jul 2026 16:30:37 -0500 Subject: [PATCH 5/9] Flag trailing-slash route collisions in duplicate detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../tests/host-routing-validation-test.ts | 10 +++++ .../runtime-common/host-routing-validation.ts | 29 +++++++++++-- packages/runtime-common/realm.ts | 8 ++-- .../tests/host-routing-validation-test.ts | 41 +++++++++++++++++++ 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/packages/realm-server/tests/host-routing-validation-test.ts b/packages/realm-server/tests/host-routing-validation-test.ts index 92b5a155fd6..e0bc21d76a3 100644 --- a/packages/realm-server/tests/host-routing-validation-test.ts +++ b/packages/realm-server/tests/host-routing-validation-test.ts @@ -59,5 +59,15 @@ module(basename(import.meta.filename), function () { test('findDuplicateRoutingPaths: treats surrounding whitespace as equivalent', async function (assert) { await runSharedTest(hostRoutingValidationTests, assert, {}); }); + + test('findDuplicateRoutingPaths: treats trailing-slash variants as the same route', async function (assert) { + await runSharedTest(hostRoutingValidationTests, assert, {}); + }); + }); + + module('normalizeRoutingPath', function () { + test('normalizeRoutingPath: strips trailing slashes and preserves the root', async function (assert) { + await runSharedTest(hostRoutingValidationTests, assert, {}); + }); }); }); diff --git a/packages/runtime-common/host-routing-validation.ts b/packages/runtime-common/host-routing-validation.ts index 0d262a7f71f..68865f46b12 100644 --- a/packages/runtime-common/host-routing-validation.ts +++ b/packages/runtime-common/host-routing-validation.ts @@ -5,6 +5,19 @@ const VALID_PATH_PATTERN = /^\/(?:[A-Za-z0-9._~/-]|%[0-9A-Fa-f]{2})*$/; +/** + * Canonical form of a routing rule path: a trailing slash is stripped so + * `/pricing/` and `/pricing` compare and match identically, with the realm + * root `/` preserved. This is the single source of truth for that + * normalization — `Realm.getHostRoutingMap` uses it to build the map, and + * `findDuplicateRoutingPaths` / `validateRoutingPath` use it so the editor's + * collision detection and advisories agree with how routes actually resolve. + * Callers that care about surrounding whitespace should `trim()` first. + */ +export function normalizeRoutingPath(path: string): string { + return path.replace(/\/+$/, '') || '/'; +} + /** * Returns a warning message for a routing rule path that is non-empty * but malformed. Empty / whitespace-only / null / undefined input @@ -37,8 +50,9 @@ export function validateRoutingPath( // input. The root '/' is the realm root, not a trailing slash, so it is // exempt. if (trimmed !== '/' && trimmed.endsWith('/')) { - let normalized = trimmed.replace(/\/+$/, '') || '/'; - return `Trailing slash is ignored; this route matches "${normalized}"`; + return `Trailing slash is ignored; this route matches "${normalizeRoutingPath( + trimmed, + )}"`; } return undefined; } @@ -47,6 +61,12 @@ export function validateRoutingPath( * Returns the set of non-empty paths that appear on more than one * routing rule, in insertion order. Empty paths are ignored — they * represent rules whose path field hasn't been filled in yet. + * + * Paths are compared in their normalized form, so `/pricing` and + * `/pricing/` collide — matching how the routing map resolves them (both + * become `/pricing`, and the map's `.find()` would otherwise make the + * second target silently unreachable). The reported path is the normalized + * one. */ export function findDuplicateRoutingPaths( rules: ReadonlyArray<{ path?: string | null }> | null | undefined, @@ -54,8 +74,9 @@ export function findDuplicateRoutingPaths( if (!rules) return []; let counts = new Map(); for (let rule of rules) { - let path = rule?.path?.trim(); - if (!path) continue; + let trimmed = rule?.path?.trim(); + if (!trimmed) continue; + let path = normalizeRoutingPath(trimmed); counts.set(path, (counts.get(path) ?? 0) + 1); } let dups: string[] = []; diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index e63215d5510..408ebabfa0e 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -44,6 +44,7 @@ import { HtmlResourceType, } from './resource-types.ts'; import { normalizeRelationships } from './relationship-utils.ts'; +import { normalizeRoutingPath } from './host-routing-validation.ts'; import type { LocalPath } from './paths.ts'; import { RealmPaths, ensureTrailingSlash, join } from './paths.ts'; import type ms from 'ms'; @@ -6799,9 +6800,10 @@ export class Realm { // matched slash-insensitively (RealmPaths.local strips trailing // slashes), so an un-normalized '/pricing/' rule would never match; // normalizing here also feeds the correct canonical form to the - // serve-index redirect and the client-side routing map. The - // realm-root rule '/' is preserved. - let normalizedPath = path.replace(/\/+$/, '') || '/'; + // serve-index redirect and the client-side routing map. Shared with + // the editor's duplicate detection so a '/pricing' + '/pricing/' + // collision is flagged there. The realm-root rule '/' is preserved. + let normalizedPath = normalizeRoutingPath(path); if (!instance || typeof instance !== 'object') return []; let id = (instance as Record).id; if (typeof id !== 'string') return []; diff --git a/packages/runtime-common/tests/host-routing-validation-test.ts b/packages/runtime-common/tests/host-routing-validation-test.ts index 1212ead931f..9f338a8cb7b 100644 --- a/packages/runtime-common/tests/host-routing-validation-test.ts +++ b/packages/runtime-common/tests/host-routing-validation-test.ts @@ -1,5 +1,6 @@ import { findDuplicateRoutingPaths, + normalizeRoutingPath, validateRoutingPath, } from '../host-routing-validation.ts'; import type { SharedTests } from '../helpers/index.ts'; @@ -150,6 +151,46 @@ const tests: SharedTests = Object.freeze({ ['/docs'], ); }, + + 'findDuplicateRoutingPaths: treats trailing-slash variants as the same route': + async (assert) => { + // The map normalizes both to '/pricing' and resolves via .find(), so + // the second target would be silently unreachable; the editor must + // flag the collision. Reported in normalized form. + assert.deepEqual( + findDuplicateRoutingPaths([ + { path: '/pricing' }, + { path: '/pricing/' }, + ]), + ['/pricing'], + ); + assert.deepEqual( + findDuplicateRoutingPaths([{ path: '/docs/' }, { path: '/docs' }]), + ['/docs'], + ); + // Root variants collapse together too. + assert.deepEqual( + findDuplicateRoutingPaths([{ path: '/' }, { path: '//' }]), + ['/'], + ); + // A genuine non-colliding pair still reports nothing. + assert.deepEqual( + findDuplicateRoutingPaths([ + { path: '/pricing' }, + { path: '/pricing-2' }, + ]), + [], + ); + }, + + 'normalizeRoutingPath: strips trailing slashes and preserves the root': + async (assert) => { + assert.strictEqual(normalizeRoutingPath('/pricing/'), '/pricing'); + assert.strictEqual(normalizeRoutingPath('/pricing'), '/pricing'); + assert.strictEqual(normalizeRoutingPath('/a/b//'), '/a/b'); + assert.strictEqual(normalizeRoutingPath('/'), '/'); + assert.strictEqual(normalizeRoutingPath('//'), '/'); + }, }); export default tests; From ac48cd74f405230688475f5abb524999d963b18e Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Tue, 21 Jul 2026 16:47:01 -0500 Subject: [PATCH 6/9] Gate host-route canonicalization behind the public-permission check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/realm-server/handlers/serve-index.ts | 96 ++++++++----------- .../server-endpoints/index-responses-test.ts | 29 ++++++ 2 files changed, 71 insertions(+), 54 deletions(-) diff --git a/packages/realm-server/handlers/serve-index.ts b/packages/realm-server/handlers/serve-index.ts index 2cb885a966e..3575ada2f92 100644 --- a/packages/realm-server/handlers/serve-index.ts +++ b/packages/realm-server/handlers/serve-index.ts @@ -302,45 +302,12 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers { ctxt.type = 'html'; - // Resolve the realm once and reuse it for canonicalization, the - // permissions check, and the routing-map lookup below. `findOrMountRealm` - // can fall back to a DB probe when the in-memory registry is cold, so we - // don't want to pay that cost more than necessary on the hot HTML path. + // Resolve the realm once and reuse it for the permissions check and the + // routing-map lookup below. `findOrMountRealm` can fall back to a DB probe + // when the in-memory registry is cold, so we don't want to pay that cost + // more than necessary on the hot HTML path. let routedRealm = await findOrMountRealm(requestURL, routingDeps); - // Canonicalize routed paths BEFORE conditional-GET handling. - // A host routing rule declared as '/pricing' matches both '/pricing' and - // '/pricing/' (RealmPaths.local strips trailing slashes). The canonical - // form is the realm mount pathname joined with the rule's declared path — - // so the realm-root rule '/' keeps its trailing slash while a sub-path - // rule has none. Redirect any other form with a 308 so bookmarks, shared - // links and crawlers converge on one URL, and so relative links in the - // served HTML resolve against a stable document base (a trailing slash - // shifts that base and breaks './'-relative hrefs in the prerendered - // page). This runs ahead of the ETag/304 block below so a repeat request - // under a non-canonical form still redirects rather than 304-ing. - let routingMap: { path: string; id: string }[] = []; - if (routedRealm) { - routingMap = await routedRealm.getHostRoutingMap(); - if (routingMap.length > 0) { - let realmURL = new URL(routedRealm.url); - realmURL.protocol = requestURL.protocol; - let pathInRealm = '/' + new RealmPaths(realmURL).local(requestURL); - let rule = routingMap.find((r) => r.path === pathInRealm); - if (rule) { - let canonicalPathname = - realmURL.pathname + rule.path.replace(/^\//, ''); - if (requestURL.pathname !== canonicalPathname) { - ctxt.redirect( - new URL(canonicalPathname + requestURL.search, requestURL).href, - ); - ctxt.status = 308; - return; - } - } - } - } - let cardURL = requestURL; let isIndexRequest = requestURL.pathname.endsWith('/'); if (isIndexRequest) { @@ -392,23 +359,44 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers { } // CS-10055: host routing rules in the realm config can map a bare path - // (e.g. /whitepaper) to a target card. When the requested path matches - // a rule, rewrite cardURL so the head/isolated/scoped CSS fetched - // below render the routed target. The same map (resolved above for - // canonicalization) is also written into the - // @cardstack/host/config/environment meta tag further down so the SPA - // can resolve the path post-hydration. - if (routedRealm && routingMap.length > 0) { - let realmURL = new URL(routedRealm.url); - realmURL.protocol = requestURL.protocol; - let realmPaths = new RealmPaths(realmURL); - let pathInRealm = '/' + realmPaths.local(requestURL); - let rule = routingMap.find((r) => r.path === pathInRealm); - if (rule) { - // The request has already been canonicalized above, so `rule` - // matches the canonical form here; rewrite cardURL so the - // head/isolated/scoped CSS fetched below render the routed target. - cardURL = new URL(rule.id); + // (e.g. /whitepaper) to a target card. This runs AFTER the public- + // permission gate above so a non-public realm never has its routing map + // consulted — otherwise the canonical redirect below would disclose that + // a private routed path exists to an unauthenticated caller. The map is + // also written into the @cardstack/host/config/environment meta tag + // further down so the SPA can resolve the path post-hydration. + let routingMap: { path: string; id: string }[] = []; + if (routedRealm) { + routingMap = await routedRealm.getHostRoutingMap(); + if (routingMap.length > 0) { + let realmURL = new URL(routedRealm.url); + realmURL.protocol = requestURL.protocol; + let realmPaths = new RealmPaths(realmURL); + let pathInRealm = '/' + realmPaths.local(requestURL); + let rule = routingMap.find((r) => r.path === pathInRealm); + if (rule) { + // Canonicalize the URL. A rule declared as '/pricing' matches both + // '/pricing' and '/pricing/' (RealmPaths.local strips trailing + // slashes); the canonical form is the realm mount pathname joined + // with the rule's declared path — so the realm-root rule '/' keeps + // its trailing slash while a sub-path rule has none. Redirect any + // other form with a 308 so bookmarks, shared links and crawlers + // converge on one URL, and so relative links in the served HTML + // resolve against a stable document base (a trailing slash shifts + // that base and breaks './'-relative hrefs in the prerendered page). + let canonicalPathname = + realmURL.pathname + rule.path.replace(/^\//, ''); + if (requestURL.pathname !== canonicalPathname) { + ctxt.redirect( + new URL(canonicalPathname + requestURL.search, requestURL).href, + ); + ctxt.status = 308; + return; + } + // Rewrite cardURL so the head/isolated/scoped CSS fetched below + // render the routed target. + cardURL = new URL(rule.id); + } } } diff --git a/packages/realm-server/tests/server-endpoints/index-responses-test.ts b/packages/realm-server/tests/server-endpoints/index-responses-test.ts index 8e9e4d20bc4..792bce5c72a 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -1870,6 +1870,35 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { '/pricing/ is redirected for */* too', ); }); + + test('a non-public realm does not disclose routes via a canonical redirect', async function (assert) { + // Drop the published realm's permissions so it is no longer publicly + // readable. `fetchRealmPermissions` reads this table uncached, so the + // next request sees the change immediately. + await dbAdapter.execute( + `DELETE FROM realm_user_permissions WHERE realm_url = '${publishedRealmURLString}'`, + ); + + let response = await request + .get(`${publishedRealmPath}pricing/`) + .set('Host', publishedRealmHost) + .set('Accept', 'text/html'); + + // The public-permission gate runs before the routing-map lookup, so a + // non-public realm falls through to the generic Boxel shell instead of + // emitting a route-specific 308 that would reveal the private route + // exists. + assert.notStrictEqual( + response.status, + 308, + 'no route-specific redirect is emitted for a non-public realm', + ); + assert.strictEqual( + response.status, + 200, + 'serves the generic shell for a non-public realm', + ); + }); }, ); }); From 3d5836e7ae3e385766a1819263bb189c99da911e Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 22 Jul 2026 11:09:09 -0500 Subject: [PATCH 7/9] Gate the bare-path route serve on public permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/realm-server/handlers/serve-index.ts | 12 +++++++++- .../server-endpoints/index-responses-test.ts | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/realm-server/handlers/serve-index.ts b/packages/realm-server/handlers/serve-index.ts index 3575ada2f92..769ca489d0f 100644 --- a/packages/realm-server/handlers/serve-index.ts +++ b/packages/realm-server/handlers/serve-index.ts @@ -255,8 +255,18 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers { // module resolver (→ 404) when the path also matches no routing // rule. Without this, the bare form 404s while the trailing-slash // form — which skips this gate via isIndexRequest — renders. + // + // The rule match is additionally gated on public read: consulting + // the routing map for a non-public realm would leak which bare + // paths are configured routes via the 200-vs-404 difference (a real + // route falls through to the generic 200 shell, a non-route 404s). + // Requiring public read makes routed and non-routed bare paths + // behave identically (both next() → 404) on a non-public realm. let matchedRule = await matchHostRoutingRule(requestURL, routingDeps); - if (!matchedRule) { + if ( + !matchedRule || + !(await hasPublicPermissions(matchedRule.realm, routingDeps)) + ) { return next(); } } diff --git a/packages/realm-server/tests/server-endpoints/index-responses-test.ts b/packages/realm-server/tests/server-endpoints/index-responses-test.ts index 792bce5c72a..6d1b76e3a20 100644 --- a/packages/realm-server/tests/server-endpoints/index-responses-test.ts +++ b/packages/realm-server/tests/server-endpoints/index-responses-test.ts @@ -1899,6 +1899,30 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { 'serves the generic shell for a non-public realm', ); }); + + test('a non-public realm does not disclose bare routes to a generic Accept header', async function (assert) { + await dbAdapter.execute( + `DELETE FROM realm_user_permissions WHERE realm_url = '${publishedRealmURLString}'`, + ); + + // The bare-path gate consults the routing map for */* requests; if it + // did so without checking public read, a real route would answer 200 + // (generic shell) while a non-route 404s — enumerating private routes. + let routed = await request + .get(`${publishedRealmPath}pricing`) // a real rule + .set('Host', publishedRealmHost) + .set('Accept', '*/*'); + let bogus = await request + .get(`${publishedRealmPath}not-a-route`) // no rule + .set('Host', publishedRealmHost) + .set('Accept', '*/*'); + + assert.strictEqual( + routed.status, + bogus.status, + `a routed and a non-routed bare path are indistinguishable on a non-public realm (routed=${routed.status}, bogus=${bogus.status})`, + ); + }); }, ); }); From e1ad34283b85c0289a915de8944ae94cfc023766 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 22 Jul 2026 11:17:49 -0500 Subject: [PATCH 8/9] Reuse matchHostRoutingRule for the HTML-path canonicalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/realm-server/handlers/serve-index.ts | 58 +++++++++---------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/packages/realm-server/handlers/serve-index.ts b/packages/realm-server/handlers/serve-index.ts index 769ca489d0f..098a9c91337 100644 --- a/packages/realm-server/handlers/serve-index.ts +++ b/packages/realm-server/handlers/serve-index.ts @@ -7,7 +7,6 @@ import { logger, param, query, - RealmPaths, sanitizeHeadHTMLToString, } from '@cardstack/runtime-common'; import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; @@ -375,38 +374,33 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers { // a private routed path exists to an unauthenticated caller. The map is // also written into the @cardstack/host/config/environment meta tag // further down so the SPA can resolve the path post-hydration. - let routingMap: { path: string; id: string }[] = []; - if (routedRealm) { - routingMap = await routedRealm.getHostRoutingMap(); - if (routingMap.length > 0) { - let realmURL = new URL(routedRealm.url); - realmURL.protocol = requestURL.protocol; - let realmPaths = new RealmPaths(realmURL); - let pathInRealm = '/' + realmPaths.local(requestURL); - let rule = routingMap.find((r) => r.path === pathInRealm); - if (rule) { - // Canonicalize the URL. A rule declared as '/pricing' matches both - // '/pricing' and '/pricing/' (RealmPaths.local strips trailing - // slashes); the canonical form is the realm mount pathname joined - // with the rule's declared path — so the realm-root rule '/' keeps - // its trailing slash while a sub-path rule has none. Redirect any - // other form with a 308 so bookmarks, shared links and crawlers - // converge on one URL, and so relative links in the served HTML - // resolve against a stable document base (a trailing slash shifts - // that base and breaks './'-relative hrefs in the prerendered page). - let canonicalPathname = - realmURL.pathname + rule.path.replace(/^\//, ''); - if (requestURL.pathname !== canonicalPathname) { - ctxt.redirect( - new URL(canonicalPathname + requestURL.search, requestURL).href, - ); - ctxt.status = 308; - return; - } - // Rewrite cardURL so the head/isolated/scoped CSS fetched below - // render the routed target. - cardURL = new URL(rule.id); + let routingMap: { path: string; id: string }[] = routedRealm + ? await routedRealm.getHostRoutingMap() + : []; + if (routingMap.length > 0) { + // The match and its canonical form live once, in matchHostRoutingRule. + let matched = await matchHostRoutingRule(requestURL, routingDeps); + if (matched) { + // Canonicalize the URL. A rule declared as '/pricing' matches both + // '/pricing' and '/pricing/' (RealmPaths.local strips trailing + // slashes); the canonical form is the realm mount pathname joined + // with the rule's declared path — so the realm-root rule '/' keeps + // its trailing slash while a sub-path rule has none. Redirect any + // other form with a 308 so bookmarks, shared links and crawlers + // converge on one URL, and so relative links in the served HTML + // resolve against a stable document base (a trailing slash shifts + // that base and breaks './'-relative hrefs in the prerendered page). + if (requestURL.pathname !== matched.canonicalPathname) { + ctxt.redirect( + new URL(matched.canonicalPathname + requestURL.search, requestURL) + .href, + ); + ctxt.status = 308; + return; } + // Rewrite cardURL so the head/isolated/scoped CSS fetched below + // render the routed target. + cardURL = new URL(matched.rule.id); } } From 1e8eb6ec74b6501a2767c5d95ef30cfbd1be9f72 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Wed, 22 Jul 2026 11:32:49 -0500 Subject: [PATCH 9/9] Fold the client's route-path canonicalizer onto the shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../host/app/services/host-mode-service.ts | 24 ++++++++----------- .../runtime-common/host-routing-validation.ts | 11 +++++---- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/host/app/services/host-mode-service.ts b/packages/host/app/services/host-mode-service.ts index 0620ceab5b4..a7318aa3583 100644 --- a/packages/host/app/services/host-mode-service.ts +++ b/packages/host/app/services/host-mode-service.ts @@ -4,7 +4,10 @@ import { tracked } from '@glimmer/tracking'; import window from 'ember-window-mock'; -import { sanitizeHeadHTML } from '@cardstack/runtime-common'; +import { + normalizeRoutingPath, + sanitizeHeadHTML, +} from '@cardstack/runtime-common'; import config from '@cardstack/host/config/environment'; @@ -20,15 +23,6 @@ function ensureSingleTitle(headHTML: string): string { : `${DEFAULT_HEAD_HTML}\n${headHTML}`; } -// Normalize trailing-slash variance for routing-map matching. `/realm/` -// and `/realm` are the same destination from the user's perspective, -// but the injected map keys and Ember's `params.path` disagree on -// the trailing slash. Stripping it on both sides makes the comparator -// robust. Preserve the root `/` since stripping it would empty the path. -function canonicalizeRoutingPath(path: string): string { - if (path === '/') return '/'; - return path.replace(/\/+$/, ''); -} import type HostModeStateService from '@cardstack/host/services/host-mode-state-service'; import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service'; import type RealmService from '@cardstack/host/services/realm'; @@ -145,13 +139,15 @@ export default class HostModeService extends Service { // rule's injected key is the realm's mount pathname WITH trailing // slash (e.g. `/progressive-cheetah/`), but Ember's catch-all strips // it (`params.path === 'progressive-cheetah'` for either visit form). - // Canonicalize both sides by stripping trailing slashes (except the - // root `/` itself) before comparing so `/realm` ↔ `/realm/` resolve. + // Canonicalize both sides with the shared `normalizeRoutingPath` (strips + // trailing slashes, preserves the root `/`) before comparing, so + // `/realm` ↔ `/realm/` resolve and the client agrees with how the server + // map builder and the editor normalize. resolveRoutedPath(path: string): string | null { let normalized = path.startsWith('/') ? path : `/${path}`; - let canonical = canonicalizeRoutingPath(normalized); + let canonical = normalizeRoutingPath(normalized); let rule = this.hostRoutingMap.find( - (r) => canonicalizeRoutingPath(r.path) === canonical, + (r) => normalizeRoutingPath(r.path) === canonical, ); return rule ? rule.id : null; } diff --git a/packages/runtime-common/host-routing-validation.ts b/packages/runtime-common/host-routing-validation.ts index 68865f46b12..41d8d255c4e 100644 --- a/packages/runtime-common/host-routing-validation.ts +++ b/packages/runtime-common/host-routing-validation.ts @@ -9,10 +9,13 @@ const VALID_PATH_PATTERN = /^\/(?:[A-Za-z0-9._~/-]|%[0-9A-Fa-f]{2})*$/; * Canonical form of a routing rule path: a trailing slash is stripped so * `/pricing/` and `/pricing` compare and match identically, with the realm * root `/` preserved. This is the single source of truth for that - * normalization — `Realm.getHostRoutingMap` uses it to build the map, and - * `findDuplicateRoutingPaths` / `validateRoutingPath` use it so the editor's - * collision detection and advisories agree with how routes actually resolve. - * Callers that care about surrounding whitespace should `trim()` first. + * normalization across every place a route path is compared: + * `Realm.getHostRoutingMap` uses it to build the map, `findDuplicateRoutingPaths` + * / `validateRoutingPath` use it so the editor's collision detection and + * advisories agree with how routes resolve, and the host's + * `HostModeService.resolveRoutedPath` uses it to match the injected map keys + * against the browser path. Callers that care about surrounding whitespace + * should `trim()` first. */ export function normalizeRoutingPath(path: string): string { return path.replace(/\/+$/, '') || '/';