diff --git a/packages/base/realm-config.gts b/packages/base/realm-config.gts index cad1a6be87..e596321049 100644 --- a/packages/base/realm-config.gts +++ b/packages/base/realm-config.gts @@ -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', }); } 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..1c76c4ce4a 100644 --- a/packages/host/app/services/host-mode-service.ts +++ b/packages/host/app/services/host-mode-service.ts @@ -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. `//whitepaper` for a request to + // `https://host///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; + } + get currentCardId() { if (this.isActive) { let stack = this.hostModeStateService.stackItems; diff --git a/packages/host/config/environment.js b/packages/host/config/environment.js index e43508a1c2..9e8eeb18fe 100644 --- a/packages/host/config/environment.js +++ b/packages/host/config/environment.js @@ -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, diff --git a/packages/matrix/tests/host-mode.spec.ts b/packages/matrix/tests/host-mode.spec.ts index 7deec360ee..9309c84926 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,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(); + }); }); diff --git a/packages/realm-server/handlers/serve-index.ts b/packages/realm-server/handlers/serve-index.ts index 29efa8ee95..fd3531def1 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)); @@ -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( @@ -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); + } + } + } + headLog.debug(`Fetching head HTML for ${cardURL.href}`); isolatedLog.debug(`Fetching isolated HTML for ${cardURL.href}`); scopedCSSLog.debug(`Fetching scoped CSS for ${cardURL.href}`); @@ -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( + /()/, + (_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( diff --git a/packages/realm-server/lib/realm-routing.ts b/packages/realm-server/lib/realm-routing.ts index 82f434a891..4015ec5e8e 100644 --- a/packages/realm-server/lib/realm-routing.ts +++ b/packages/realm-server/lib/realm-routing.ts @@ -214,11 +214,9 @@ export async function hasExtensionlessSourceModule( } export async function hasPublicPermissions( - cardURL: URL, + realm: Realm | undefined, deps: RealmRoutingDeps, ): Promise { - let realm = await findOrMountRealm(cardURL, deps); - if (!realm) { return false; } diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index a534dd1e41..e7ba090174 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -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', diff --git a/packages/realm-server/tests/realm-routing-test.ts b/packages/realm-server/tests/realm-routing-test.ts new file mode 100644 index 0000000000..3c25060c82 --- /dev/null +++ b/packages/realm-server/tests/realm-routing-test.ts @@ -0,0 +1,98 @@ +import { module, test } from 'qunit'; +import { basename } from 'path'; +import { rri } from '@cardstack/runtime-common'; +import type { LooseSingleCardDocument, Realm } from '@cardstack/runtime-common'; +import { setupPermissionedRealmCached } from './helpers'; + +// 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. +function makeRoutingFixture(): Record< + string, + string | LooseSingleCardDocument +> { + return { + 'white-paper.gts': ` + import { CardDef, Component } from "https://cardstack.com/base/card-api"; + export class WhitePaper extends CardDef { + static displayName = 'White Paper'; + static isolated = class Isolated extends Component { + + } + } + `, + 'white-paper.json': { + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { module: rri('./white-paper'), name: 'WhitePaper' }, + }, + }, + }, + 'realm.json': { + data: { + type: 'card', + attributes: { + cardInfo: { name: 'Routing Test Realm' }, + // `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' }], + }, + relationships: { + 'hostRoutingRules.0.instance': { + links: { self: './white-paper' }, + }, + // Cross-realm: must be filtered out by the same-realm + // guard. The project spec restricts routing rules to cards + // within the same realm; this verifies the read path + // enforces that even when the UI guard is bypassed by + // hand-editing realm.json. + 'hostRoutingRules.1.instance': { + links: { self: 'http://otherrealm.test/x' }, + }, + }, + meta: { + adoptsFrom: { + module: rri('https://cardstack.com/base/realm-config'), + name: 'RealmConfig', + }, + }, + }, + }, + }; +} + +module(basename(__filename), function () { + module('Realm.getHostRoutingMap', function (hooks) { + let realmURL = new URL('http://127.0.0.1:4444/routing-unit/'); + let testRealm: Realm; + + setupPermissionedRealmCached(hooks, { + realmURL, + permissions: { '*': ['read'] }, + fileSystem: makeRoutingFixture(), + onRealmSetup({ testRealm: realm }) { + testRealm = realm; + }, + }); + + hooks.beforeEach(async function () { + await testRealm.indexing(); + }); + + test('resolves relative references and drops cross-realm rules', 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', + ); + }); + }); +}); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 7c88fa6d39..fd94dba16a 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -6087,6 +6087,65 @@ export class Realm { } } + // CS-10054: read host routing rules from the indexed RealmConfig card. + // The `instance` field is `linksTo(CardDef)`, so the indexed + // searchDoc flattens each rule's link as `{ id, ...flattened + // linked-card attrs }`. We only need the absolute `id` here. + // Returns absolute URLs. + async getHostRoutingMap(): Promise<{ path: string; id: string }[]> { + let realmConfigCardURL = new URL( + this.paths.fileURL('realm.json').href.replace(/\.json$/, ''), + ); + try { + let indexEntry = + await this.#realmIndexQueryEngine.instance(realmConfigCardURL); + if (indexEntry?.type !== 'instance') { + return []; + } + let rules = (indexEntry.searchDoc ?? {}).hostRoutingRules; + if (!Array.isArray(rules)) { + return []; + } + return rules.flatMap((rule) => { + if (!rule || typeof rule !== 'object') return []; + let path = (rule as Record).path; + let instance = (rule as Record).instance; + if (typeof path !== 'string') return []; + if (!instance || typeof instance !== 'object') return []; + let id = (instance as Record).id; + if (typeof id !== 'string') return []; + let idURL: URL; + try { + idURL = new URL(id); + } catch { + return []; + } + // Defensive same-realm guard. The project spec restricts + // routing rules to cards within the same realm; CS-10052 + // enforces that in the UI but the file is hand-editable, so + // the read path filters too. Without this guard a realm owner + // could point `instance` at a private realm's card and the + // serve-index cardURL rewrite would surface its prerendered + // HTML through their public realm's routed path. `inRealm` + // is URL-aware, so neighbouring realms with shared prefixes + // (`/realm-evil/` vs `/realm/`) and trailing-slash variance + // are handled correctly. + if (!this.paths.inRealm(idURL)) { + this.#log.warn( + `dropping host routing rule for path "${path}" — target ${id} is outside this realm`, + ); + return []; + } + return [{ path, id }]; + }); + } catch (e) { + this.#log.warn( + `failed to read host routing map from RealmConfig card: ${e}`, + ); + return []; + } + } + // Upserts the patch into realm_metadata for this realm. Only the // provided keys are written; absent keys retain their existing column // values via COALESCE on the EXCLUDED row's NULL. Pass an explicit