diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index ee6a8b6027..9cd174c926 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -4859,11 +4859,13 @@ export function virtualNetworkFor( // Resolve a (possibly prefix-form or relative) reference to an absolute URL // string through the supplied VirtualNetwork. When the caller can't supply // one (test stubs, detached instances), fall back to plain URL math: it -// covers URL-form refs and relative refs against URL-form bases. Prefix-form -// refs and refs against prefix-form bases can't be resolved without a VN — -// `new URL()` throws on those, so we return the raw reference unchanged -// instead of bubbling the error to callers (e.g. relationship deserialize -// uses the returned string as a "did this resolve?" signal). +// covers URL-form refs and relative refs against URL-form bases. A scoped +// prefix-form RRI (`@scope/realm/...`) has no meaning without realm mappings, +// and `new URL('@scope/realm/x', urlBase)` would NOT throw — it silently +// path-joins the reference into a bogus URL (it only throws when there is no +// base at all). So return such references unchanged rather than fabricating a +// URL; relationship deserialize treats an unresolved reference as a "not +// loaded" signal. export function resolveRef( virtualNetwork: VirtualNetwork | undefined, reference: string, @@ -4872,6 +4874,9 @@ export function resolveRef( if (virtualNetwork) { return virtualNetwork.resolveURL(reference, relativeTo).href; } + if (reference.startsWith('@')) { + return reference; + } let base: URL | string | undefined; if (relativeTo instanceof URL) { base = relativeTo; diff --git a/packages/realm-server/tests/realm-identifiers-test.ts b/packages/realm-server/tests/realm-identifiers-test.ts index d064a93f9f..401b09132f 100644 --- a/packages/realm-server/tests/realm-identifiers-test.ts +++ b/packages/realm-server/tests/realm-identifiers-test.ts @@ -670,6 +670,51 @@ module(basename(import.meta.filename), function () { ); }); }); + + module('scoped relationship-link resolution', function () { + // A relationship link (links.self) to an instance in another realm is + // written in scoped RRI prefix form — e.g. a homepage card linking to a + // base-realm Theme instance. It must resolve to that realm (not be + // path-joined onto the referring card's URL) when the prefix is + // registered, and must fail loudly — not silently mangle — when it isn't. + test('resolves a scoped instance link to its realm when the prefix is registered', function (assert) { + let vn = makeVN(); + assert.strictEqual( + vn.resolveURL( + '@cardstack/base/Theme/boxel-brand-guide', + new URL('http://localhost:4201/homepage/Site/boxel-site.json'), + ).href, + 'http://localhost:4201/base/Theme/boxel-brand-guide', + ); + }); + + test('keeps a registered scoped link in canonical prefix form', function (assert) { + let vn = makeVN(); + assert.strictEqual( + vn.resolveRRI( + '@cardstack/base/Theme/boxel-brand-guide', + rri('http://localhost:4201/homepage/Site/boxel-site.json'), + ), + '@cardstack/base/Theme/boxel-brand-guide', + ); + }); + + test('throws for a scoped link whose prefix has no registered mapping, instead of silently mangling', function (assert) { + // Without the guard, resolving against the referring card's URL + // produced a bogus path-join like + // `http://localhost:4201/homepage/Site/@cardstack/base/Theme/...`, + // which 404s and surfaces only as a confusing downstream error. + let vn = new VirtualNetwork(); + assert.throws( + () => + vn.resolveURL( + '@cardstack/base/Theme/boxel-brand-guide', + new URL('http://localhost:4201/homepage/Site/boxel-site.json'), + ), + /no mapping registered for that prefix/, + ); + }); + }); }); module('VirtualNetwork.fetch with RRI', function (hooks) { diff --git a/packages/runtime-common/helpers/card-type-display-name.ts b/packages/runtime-common/helpers/card-type-display-name.ts index 02df0bde08..c67d81d4e6 100644 --- a/packages/runtime-common/helpers/card-type-display-name.ts +++ b/packages/runtime-common/helpers/card-type-display-name.ts @@ -7,6 +7,14 @@ import type { import { getField } from '../code-ref.ts'; export function cardTypeDisplayName(cardOrField: BaseDef): string { + // A not-yet-loaded or broken relationship link can surface an undefined + // model to a card's own template (the linksTo component only renders the + // broken-link template for specific membership states). Guard like the + // sibling helpers below so an unguarded `{{cardTypeDisplayName @model}}` + // renders empty instead of throwing and failing the whole card render. + if (!cardOrField?.constructor) { + return ''; + } return cardOrField.constructor.getDisplayName(cardOrField); } diff --git a/packages/runtime-common/index-writer.ts b/packages/runtime-common/index-writer.ts index d98864311e..8f7bf362de 100644 --- a/packages/runtime-common/index-writer.ts +++ b/packages/runtime-common/index-writer.ts @@ -359,8 +359,13 @@ export class Batch { ]), ] as Expression)) as unknown as BoxelIndexTable[]; let now = String(Date.now()); + // A scoped RRI (@scope/realm/...) is an absolute cross-realm identifier — + // preserve it verbatim whether or not this writer's VirtualNetwork has the + // prefix registered. Only source-realm URLs get rewritten to the + // destination realm; `new URL()` throws on a bare scoped prefix, so the + // guard must cover unregistered prefixes too. let copyURL = (value: string) => - this.isRegisteredPrefix(value) + value.startsWith('@') || this.isRegisteredPrefix(value) ? value : this.copiedRealmURL(sourceRealmURL, new URL(value)).href; let values = sources.map((entry) => { @@ -1433,7 +1438,12 @@ export class Batch { ): Record { let result: Record = {}; for (let [key, value] of Object.entries(obj)) { - result[this.copiedRealmURL(fromRealm, new URL(key)).href] = value; + // Scoped RRI keys are cross-realm — preserve verbatim (see copyFrom). + let newKey = + key.startsWith('@') || this.isRegisteredPrefix(key) + ? key + : this.copiedRealmURL(fromRealm, new URL(key)).href; + result[newKey] = value; } return result; } @@ -1489,9 +1499,10 @@ export class Batch { obj.id && typeof obj.id === 'string' ) { - obj.id = this.isRegisteredPrefix(obj.id) - ? obj.id - : this.copiedRealmURL(fromRealm, new URL(obj.id)); + obj.id = + obj.id.startsWith('@') || this.isRegisteredPrefix(obj.id) + ? obj.id + : this.copiedRealmURL(fromRealm, new URL(obj.id)); } else { this.updateIds(obj[key], fromRealm); } diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 778b2ede08..e5eb30a40a 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -1232,6 +1232,16 @@ export function internalKeyFor( virtualNetwork: VirtualNetwork, ): string { if (!('type' in ref)) { + // A scoped RRI (@scope/realm/...) for a realm with no registered mapping + // is already the canonical module key — use it as-is. Routing it through + // resolveURL would require a mapping (which a caller's VN may lack, e.g. + // an index writer keying a base ref) and now throws for unknown prefixes. + if ( + ref.module.startsWith('@') && + !virtualNetwork.isRegisteredPrefix(ref.module) + ) { + return `${trimExecutableExtension(rri(ref.module))}/${ref.name}`; + } let resolved = virtualNetwork.resolveURL(ref.module, relativeTo).href; let module: string = trimExecutableExtension(rri(resolved)); // Use the prefix form (e.g. @cardstack/catalog/foo) as the canonical @@ -1259,6 +1269,14 @@ export function internalKeysFor( virtualNetwork: VirtualNetwork, ): string[] { if (!('type' in ref)) { + // See internalKeyFor: an unregistered scoped RRI is already canonical and + // has no other equivalent spelling, so key it directly without resolving. + if ( + ref.module.startsWith('@') && + !virtualNetwork.isRegisteredPrefix(ref.module) + ) { + return [`${trimExecutableExtension(rri(ref.module))}/${ref.name}`]; + } let resolved = virtualNetwork.resolveURL(ref.module, relativeTo).href; let module: string = trimExecutableExtension(rri(resolved)); return virtualNetwork diff --git a/packages/runtime-common/virtual-network.ts b/packages/runtime-common/virtual-network.ts index c8a24423a2..e4dc382264 100644 --- a/packages/runtime-common/virtual-network.ts +++ b/packages/runtime-common/virtual-network.ts @@ -297,6 +297,22 @@ export class VirtualNetwork { return reference as RealmResourceIdentifier; } + // An `@`-scoped reference is RRI prefix form for a realm this network has + // no mapping registered for. It is absolute and cross-realm by + // construction, so it cannot be resolved here — and the URL-relative + // fallback below would silently path-join it into a bogus URL + // (e.g. `https://realm/card/@cardstack/base/x`), producing broken links + // and dependency entries. Fail loudly so a missing realm mapping surfaces + // at resolution time. (Serialization/relativization preserves scoped refs + // verbatim and never reaches this method — see `isScopedReference` in + // realm-index-query-engine; `canonicalURL` catches this and keeps the + // reference in clean prefix form.) + if (reference.startsWith('@')) { + throw new Error( + `Cannot resolve "${reference}": it is a scoped realm-prefix (RRI) reference, but this VirtualNetwork has no mapping registered for that prefix`, + ); + } + // "/" and "~/" are not valid RRI reference forms if (reference.startsWith('/') || reference.startsWith('~/')) { throw new Error(