Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 10 additions & 5 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions packages/realm-server/tests/realm-identifiers-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions packages/runtime-common/helpers/card-type-display-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
21 changes: 16 additions & 5 deletions packages/runtime-common/index-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -1433,7 +1438,12 @@ export class Batch {
): Record<string, any> {
let result: Record<string, any> = {};
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;
}
Expand Down Expand Up @@ -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);
}
Expand Down
18 changes: 18 additions & 0 deletions packages/runtime-common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/runtime-common/virtual-network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down