diff --git a/packages/base/realm-config.gts b/packages/base/realm-config.gts
index cad1a6be87..e2c0f349b6 100644
--- a/packages/base/realm-config.gts
+++ b/packages/base/realm-config.gts
@@ -4,7 +4,6 @@ import {
field,
contains,
containsMany,
- linksTo,
} from './card-api';
import BooleanField from './boolean';
import StringField from './string';
@@ -19,9 +18,14 @@ export class RoutingRuleField extends FieldDef {
description: 'Static path within the realm, e.g. "/" or "/pricing"',
});
- @field instance = linksTo(CardDef, {
+ // Card URL of the instance to render when the realm is navigated at
+ // `path`. Stored as a string (rather than a `linksTo`) so the rule
+ // serializes flat alongside `path` inside `attributes` — no JSON:API
+ // relationships split. Relative URLs (e.g. `./whitepaper`) are
+ // resolved against the realm root by the routing-map reader.
+ @field instance = contains(StringField, {
description:
- 'Card instance to render when the realm is navigated at the given path',
+ 'Card URL to render at this path. Relative URLs are resolved against the realm root.',
});
}
diff --git a/packages/host/app/routes/index.gts b/packages/host/app/routes/index.gts
index 8a9f08e6cb..5e5b73e246 100644
--- a/packages/host/app/routes/index.gts
+++ b/packages/host/app/routes/index.gts
@@ -81,7 +81,15 @@ export default class Card extends Route {
}) {
if (this.hostModeService.isActive) {
let normalizedPath = params.path ?? '';
- let cardUrl = `${this.hostModeService.hostModeOrigin}/${normalizedPath}`;
+ // CS-10055: a routing rule in the realm config can map a bare path
+ // to a target card. When the path matches a rule, use the rule's
+ // target id directly; otherwise resolve the path as a card URL
+ // under the host-mode origin.
+ let routedId = this.hostModeService.resolveRoutedPath(
+ normalizedPath || '/',
+ );
+ let cardUrl =
+ routedId ?? `${this.hostModeService.hostModeOrigin}/${normalizedPath}`;
return this.store.get(cardUrl);
}
diff --git a/packages/host/app/services/host-mode-service.ts b/packages/host/app/services/host-mode-service.ts
index e125d9fa26..df7e36ab7f 100644
--- a/packages/host/app/services/host-mode-service.ts
+++ b/packages/host/app/services/host-mode-service.ts
@@ -113,6 +113,23 @@ export default class HostModeService extends Service {
return this.operatorModeStateService.realmURL;
}
+ // CS-10055: routing rules from the realm config card. The realm-server
+ // injects this map as `window.__hostRoutingMap` in the SPA shell so the
+ // first-render decision in the index route is synchronous.
+ get hostRoutingMap(): { path: string; id: string }[] {
+ let map = (window as { __hostRoutingMap?: unknown }).__hostRoutingMap;
+ return Array.isArray(map) ? (map as { path: string; id: string }[]) : [];
+ }
+
+ // Returns the target card id if `path` matches a routing rule, else null.
+ // `path` is the path within the realm; a leading slash is added if absent
+ // so the index path is matchable as either '' or '/'.
+ resolveRoutedPath(path: string): string | null {
+ let normalized = path.startsWith('/') ? path : `/${path}`;
+ let rule = this.hostRoutingMap.find((r) => r.path === normalized);
+ return rule ? rule.id : null;
+ }
+
get currentCardId() {
if (this.isActive) {
let stack = this.hostModeStateService.stackItems;
diff --git a/packages/matrix/tests/host-mode.spec.ts b/packages/matrix/tests/host-mode.spec.ts
index 7deec360ee..6ae0c8174a 100644
--- a/packages/matrix/tests/host-mode.spec.ts
+++ b/packages/matrix/tests/host-mode.spec.ts
@@ -2,6 +2,7 @@ import { expect, test } from './fixtures';
import {
createRealm,
createSubscribedUserAndLogin,
+ login,
logout,
postCardSource,
waitUntil,
@@ -10,6 +11,7 @@ import { appURL } from '../helpers/isolated-realm-server';
import { randomUUID } from 'crypto';
test.describe('Host mode', () => {
+ let realmURL: string;
let publishedRealmURL: string;
let publishedCardURL: string;
let publishedWhitePaperCardURL: string;
@@ -31,7 +33,7 @@ test.describe('Host mode', () => {
const realmName = `host-mode-${randomUUID()}`;
await createRealm(page, realmName);
- const realmURL = new URL(`${username}/${realmName}/`, serverIndexUrl).href;
+ realmURL = new URL(`${username}/${realmName}/`, serverIndexUrl).href;
await page.goto(realmURL);
await page.locator('[data-test-stack-item-content]').first().waitFor();
@@ -340,4 +342,95 @@ test.describe('Host mode', () => {
const pageTitle = await page.title();
expect(pageTitle).toBe('My Custom Title From Head Template');
});
+
+ // CS-10054 + CS-10055: routing rules in the realm config card resolve a
+ // bare path (no .json extension) to a target card and render it in host
+ // mode. This test fails until the host-mode request handler reads the
+ // routing map from the indexed RealmConfig card and applies it.
+ test('routing rule resolves a bare path to its target card', async ({
+ page,
+ }) => {
+ // beforeEach logged out — re-login so we can write to the source realm.
+ await login(page, username, password);
+ await page.goto(realmURL);
+ await page.locator('[data-test-stack-item-content]').first().waitFor();
+
+ // Overwrite realm.json with a routing rule mapping /whitepaper to the
+ // existing white-paper card. The auto-generated realm.json from
+ // createRealm has no rules; we replace it before re-publishing.
+ await postCardSource(
+ page,
+ realmURL,
+ 'realm.json',
+ JSON.stringify({
+ data: {
+ type: 'card',
+ attributes: {
+ cardInfo: { name: `Routed Realm ${randomUUID()}` },
+ hostRoutingRules: [
+ { path: '/whitepaper', instance: './white-paper' },
+ ],
+ },
+ meta: {
+ adoptsFrom: {
+ module: 'https://cardstack.com/base/realm-config',
+ name: 'RealmConfig',
+ },
+ },
+ },
+ }),
+ );
+
+ // Re-publish so the routing rule lands in the published realm.
+ await page.evaluate(
+ async ({ realmURL, publishedRealmURL }) => {
+ let sessions = JSON.parse(
+ window.localStorage.getItem('boxel-session') ?? '{}',
+ );
+ let token = sessions[realmURL];
+ if (!token) {
+ throw new Error(`No session token found for ${realmURL}`);
+ }
+ let response = await fetch('https://localhost:4205/_publish-realm', {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ Authorization: token,
+ },
+ body: JSON.stringify({
+ sourceRealmURL: realmURL,
+ publishedRealmURL,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error(await response.text());
+ }
+ },
+ { realmURL, publishedRealmURL },
+ );
+
+ await logout(page);
+
+ // The _publish-realm POST returns 202 before the published realm has
+ // finished re-indexing the new realm.json. Poll the bare URL until the
+ // server-rendered HTML contains the target card's marker — that
+ // confirms the routing rule is indexed AND the server cardURL rewrite
+ // is applying it. Mirrors the waitUntil pattern in the
+ // `published card response` test above.
+ let routedURL = `${publishedRealmURL}whitepaper`;
+ await waitUntil(async () => {
+ let response = await page.request.get(routedURL, {
+ headers: { Accept: 'text/html' },
+ });
+ if (!response.ok()) {
+ return false;
+ }
+ let text = await response.text();
+ return text.includes('data-test-white-paper');
+ });
+
+ await page.goto(routedURL);
+ await expect(page.locator('[data-test-white-paper]')).toBeVisible();
+ });
});
diff --git a/packages/realm-server/handlers/serve-index.ts b/packages/realm-server/handlers/serve-index.ts
index 29efa8ee95..7a76c7c061 100644
--- a/packages/realm-server/handlers/serve-index.ts
+++ b/packages/realm-server/handlers/serve-index.ts
@@ -7,6 +7,7 @@ import {
logger,
param,
query,
+ RealmPaths,
sanitizeHeadHTMLToString,
} from '@cardstack/runtime-common';
import type { MatrixClient } from '@cardstack/runtime-common/matrix-client';
@@ -19,6 +20,7 @@ import {
} from '../lib/index-html-injection';
import { retrieveScopedCSS } from '../lib/retrieve-scoped-css';
import {
+ findOrMountRealm,
getPublishedRealmInfo,
hasPublicPermissions,
isIndexedCardInstance,
@@ -96,7 +98,9 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
let work = (async () => {
let indexHTML = (await getIndexHTML()).replace(
- /()/,
+ // Closing matches both HTML5-style `">` and Vite's XHTML-style `" />`
+ // so the rewrite runs against both production build and Vite dev HTML.
+ /()/,
(_match, g1, g2, g3) => {
let config = JSON.parse(decodeURIComponent(g2));
@@ -330,6 +334,27 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
return;
}
+ // 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 injected as
+ // a ` or `