Skip to content
Merged
3 changes: 3 additions & 0 deletions packages/base/realm-config.gts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ class RoutingRuleEdit extends Component<typeof RoutingRuleField> {
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);
Expand Down
24 changes: 10 additions & 14 deletions packages/host/app/services/host-mode-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
82 changes: 59 additions & 23 deletions packages/realm-server/handlers/serve-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
logger,
param,
query,
RealmPaths,
sanitizeHeadHTMLToString,
} from '@cardstack/runtime-common';
import type { MatrixClient } from '@cardstack/runtime-common/matrix-client';
Expand All @@ -25,6 +24,7 @@ import {
getPublishedRealmInfo,
hasPublicPermissions,
isIndexedCardInstance,
matchHostRoutingRule,
type RealmRoutingDeps,
} from '../lib/realm-routing.ts';
import type { RealmRegistryReconciler } from '../lib/realm-registry-reconciler.ts';
Expand Down Expand Up @@ -248,7 +248,26 @@ 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.
//
// 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 ||
!(await hasPublicPermissions(matchedRule.realm, routingDeps))
) {
return next();
}
Comment thread
backspace marked this conversation as resolved.
}
}
}
Expand Down Expand Up @@ -292,6 +311,12 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {

ctxt.type = 'html';

// 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);

let cardURL = requestURL;
let isIndexRequest = requestURL.pathname.endsWith('/');
if (isIndexRequest) {
Expand Down Expand Up @@ -329,11 +354,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,
Expand All @@ -348,23 +368,39 @@ 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);
// (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 }[] = 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);
}
}

Expand Down
54 changes: 54 additions & 0 deletions packages/realm-server/lib/realm-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MatchedHostRoutingRule | undefined> {
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,
Expand Down
14 changes: 14 additions & 0 deletions packages/realm-server/tests/host-routing-validation-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {});
});
Expand All @@ -55,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, {});
});
});
});
25 changes: 19 additions & 6 deletions packages/realm-server/tests/realm-routing-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.<i>.instance`), not inline in attributes.
hostRoutingRules: [{ path: '/rel' }, { path: '/foreign' }],
hostRoutingRules: [
{ path: '/rel' },
{ path: '/foreign' },
{ path: '/trailing/' },
],
},
relationships: {
'hostRoutingRules.0.instance': {
Expand All @@ -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: {
Expand Down Expand Up @@ -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',
);
});

Expand Down
Loading
Loading