Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1e18fda
Add Matrix test for host mode routing
backspace May 7, 2026
bb118a8
Add realm-server TDD test for Realm.getHostRoutingMap
backspace May 7, 2026
d83c786
Add import for new test file
backspace May 7, 2026
ae2d35e
Add routing
backspace May 7, 2026
6b1f766
Add routing implementation
backspace May 12, 2026
21e0f0a
Merge remote-tracking branch 'origin/main' into routing-from-config-c…
backspace May 12, 2026
24c047a
Add fix after merge
backspace May 12, 2026
e87474b
Fix Ember environment override
backspace May 12, 2026
2951d5d
Fix calculation of relative routes
backspace May 12, 2026
455582d
Fix Matrix test
backspace May 12, 2026
416c010
Merge remote-tracking branch 'origin/main' into routing-from-config-c…
backspace May 13, 2026
ac87e19
Merge remote-tracking branch 'origin/main' into routing-from-config-c…
backspace May 14, 2026
5a5cc63
Merge remote-tracking branch 'origin/main' into routing-from-config-c…
backspace May 14, 2026
11f2ce1
Merge remote-tracking branch 'origin/main' into routing-from-config-c…
backspace May 15, 2026
5b41a53
Add lint autofix
backspace May 15, 2026
3a95d04
Update structure of route map
backspace May 15, 2026
eaa4ee3
Merge remote-tracking branch 'origin/main' into routing-from-config-c…
backspace May 19, 2026
8e42fa0
Fix publish call
backspace May 19, 2026
35641c1
Add filter for extra-realm requests
backspace May 19, 2026
2e09cfd
Change to use RRIs
backspace May 20, 2026
abe813f
Change how routing map is passed to Ember
backspace May 20, 2026
e2ac7e4
Merge remote-tracking branch 'origin/main' into routing-from-config-c…
backspace May 20, 2026
ffde2e6
Fix comment wording
backspace May 20, 2026
90150ab
Fix another comment wording
backspace May 20, 2026
7f5c8e4
Simplify host routing rules to URL-only references
backspace May 20, 2026
70f0a02
Use RealmPaths.inRealm for same-realm guard instead of startsWith
backspace May 20, 2026
475505b
serve-index: resolve realm once for permissions + routing-map
backspace May 20, 2026
e3e00b7
host-mode-service: correct resolveRoutedPath comment
backspace May 20, 2026
f93d5ae
Add lint autofix
backspace May 20, 2026
60aa1ba
Merge remote-tracking branch 'origin/main' into routing-from-config-c…
backspace May 20, 2026
0d7b009
Switch RoutingRuleField.instance back to linksTo(CardDef)
backspace May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/base/realm-config.gts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class RoutingRuleField extends FieldDef {

@field instance = linksTo(CardDef, {
description:
'Card instance to render when the realm is navigated at the given path',
'Card instance to render when the realm is navigated at this path',
});
}

Expand Down
10 changes: 9 additions & 1 deletion packages/host/app/routes/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
24 changes: 24 additions & 0 deletions packages/host/app/services/host-mode-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,30 @@ export default class HostModeService extends Service {
return this.operatorModeStateService.realmURL;
}

// CS-10055: routing rules from the realm config card. The realm-server
// merges this into the @cardstack/host/config/environment meta tag
// per-request when the request hits a realm whose config card has
// hostRoutingRules — so the first-render decision in the index route
// is synchronous and the field is part of the typed config surface
// rather than a window global.
get hostRoutingMap(): { path: string; id: string }[] {
let map = (config 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 URL pathname on the host (what Ember's `/*path` catch-all
// route delivers — e.g. `<user>/<realm>/whitepaper` for a request to
// `https://host/<user>/<realm>/whitepaper`); a leading slash is added if
// absent so the index path is matchable as either '' or '/'. The
// server prefixes each rule's `path` with the realm's mount pathname
// before injecting the map, so the two sides line up as direct equality.
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;
Comment thread
backspace marked this conversation as resolved.
}

get currentCardId() {
if (this.isActive) {
let stack = this.hostModeStateService.stackItems;
Expand Down
3 changes: 3 additions & 0 deletions packages/host/config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ module.exports = function (environment) {

// the fields below may be rewritten by the realm server
hostsOwnAssets: true,
// CS-10055: realm-server injects per-request when the request is for
// a realm whose config card has hostRoutingRules; empty otherwise.
hostRoutingMap: [],
realmServerURL: process.env.REALM_SERVER_DOMAIN || defaults.realmServerURL,
resolvedBaseRealmURL:
process.env.RESOLVED_BASE_REALM_URL || defaults.baseRealmURL,
Expand Down
98 changes: 97 additions & 1 deletion packages/matrix/tests/host-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect, test } from './fixtures';
import {
createRealm,
createSubscribedUserAndLogin,
login,
logout,
postCardSource,
waitUntil,
Expand All @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -340,4 +342,98 @@ 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' }],
},
relationships: {
'hostRoutingRules.0.instance': {
links: { self: './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();
});
});
66 changes: 64 additions & 2 deletions packages/realm-server/handlers/serve-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
logger,
param,
query,
RealmPaths,
sanitizeHeadHTMLToString,
} from '@cardstack/runtime-common';
import type { MatrixClient } from '@cardstack/runtime-common/matrix-client';
Expand All @@ -19,6 +20,7 @@ import {
} from '../lib/index-html-injection';
import { retrieveScopedCSS } from '../lib/retrieve-scoped-css';
import {
findOrMountRealm,
getPublishedRealmInfo,
hasPublicPermissions,
isIndexedCardInstance,
Expand Down Expand Up @@ -96,7 +98,9 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {

let work = (async () => {
let indexHTML = (await getIndexHTML()).replace(
/(<meta name="@cardstack\/host\/config\/environment" content=")([^"].*)(">)/,
// Closing matches both HTML5-style `">` and Vite's XHTML-style `" />`
// so the rewrite runs against both production build and Vite dev HTML.
/(<meta name="@cardstack\/host\/config\/environment" content=")([^"].*?)("\s*\/?>)/,
(_match, g1, g2, g3) => {
let config = JSON.parse(decodeURIComponent(g2));

Expand Down Expand Up @@ -320,7 +324,15 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
return;
}
}
let publicPermissions = await hasPublicPermissions(cardURL, routingDeps);
// 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,
);

if (!publicPermissions) {
ctxt.body = injectHeadHTML(
Expand All @@ -330,6 +342,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 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);
Comment thread
backspace marked this conversation as resolved.
}
}
}

headLog.debug(`Fetching head HTML for ${cardURL.href}`);
isolatedLog.debug(`Fetching isolated HTML for ${cardURL.href}`);
scopedCSSLog.debug(`Fetching scoped CSS for ${cardURL.href}`);
Expand Down Expand Up @@ -423,6 +456,35 @@ export function createServeIndex(deps: ServeIndexDeps): ServeIndexHandlers {
responseHTML = injectHeadHTML(responseHTML, headFragments.join('\n'));
}

if (routingMap.length > 0 && routedRealm) {
// Rules are stored realm-relative ('/whitepaper'). The client sees URL
// paths that include the realm's mount segment ('/routing/whitepaper'
// when the realm is mounted at '/routing/' on the published host). For
// the SPA's path lookup to be a direct equality match, prefix each
// rule path with the realm's pathname before serializing.
let realmPathname = new URL(routedRealm.url).pathname;
let hostScopedMap = routingMap.map((rule) => ({
path: realmPathname + rule.path.replace(/^\//, ''),
id: rule.id,
}));
// Per-request merge into the already-rewritten config meta tag.
// The retrieveIndexHTML rewrite is cached process-wide because the
// fields it touches are global; the routing map is per-realm so it
// can't share that cache. This second regex pass parses the URL-
// encoded JSON, sets hostRoutingMap, and re-encodes — keeping the
// routing data on the same typed channel the host already reads
// for hostsOwnAssets / realmServerURL / matrixURL etc., rather
// than via a separate `window.__hostRoutingMap` global.
responseHTML = responseHTML.replace(
/(<meta name="@cardstack\/host\/config\/environment" content=")([^"]+)("\s*\/?>)/,
(_match, g1, g2, g3) => {
let cfg = JSON.parse(decodeURIComponent(g2));
cfg.hostRoutingMap = hostScopedMap;
return `${g1}${encodeURIComponent(JSON.stringify(cfg))}${g3}`;
},
);
}

if (isolatedHTML != null) {
isolatedLog.debug(
`Injecting isolated HTML for ${cardURL.href} (length ${isolatedHTML.length})\n${truncateLogLines(
Expand Down
4 changes: 1 addition & 3 deletions packages/realm-server/lib/realm-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,9 @@ export async function hasExtensionlessSourceModule(
}

export async function hasPublicPermissions(
cardURL: URL,
realm: Realm | undefined,
deps: RealmRoutingDeps,
): Promise<boolean> {
let realm = await findOrMountRealm(cardURL, deps);

if (!realm) {
return false;
}
Expand Down
1 change: 1 addition & 0 deletions packages/realm-server/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ const ALL_TEST_FILES: string[] = [
'./realm-registry-writes-test',
'./realm-file-changes-listener-test',
'./realm-index-updated-listener-test',
'./realm-routing-test',
'./module-cache-invalidation-listener-test',
'./pg-adapter-subscribe-test',
'./module-cache-coordination-test',
Expand Down
Loading
Loading