Skip to content
119 changes: 119 additions & 0 deletions packages/realm-server/tests/card-endpoints-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ import type {
import {
baseRealm,
isSingleCardDocument,
registerCardReferencePrefix,
unregisterCardReferencePrefix,
type LooseSingleCardDocument,
type SingleCardDocument,
} from '@cardstack/runtime-common';
import { parse } from 'qs';
import type { Query } from '@cardstack/runtime-common/query';
import {
setupPermissionedRealm,
setupPermissionedRealmCached,
setupPermissionedRealmsCached,
setupMatrixRoom,
Expand Down Expand Up @@ -941,6 +944,122 @@ module(basename(__filename), function () {
});
});

module('prefix-form card IDs with relationships', function (hooks) {
let prefixForTest = '@test-e2e/realm/';

// Register prefix BEFORE setupPermissionedRealm so it's active during
// indexing. This causes card IDs in the index to be stored in prefix
// form (e.g. @test-e2e/realm/Pet/vangogh) via unresolveResourceInstanceURLs.
hooks.beforeEach(function () {
registerCardReferencePrefix(prefixForTest, testRealmHref);
});

setupPermissionedRealm(hooks, {
realmURL,
permissions: {
'*': ['read', 'write'],
'@node-test_realm:localhost': ['read', 'realm-owner'],
},
onRealmSetup,
});

hooks.afterEach(function () {
unregisterCardReferencePrefix(prefixForTest);
});

test('resolves linksTo relationship when card ID is in prefix form', async function (assert) {
let { testRealm: realm, request } = getRealmSetup();

let writes = new Map<string, string>([
[
'pet.gts',
`
import { CardDef, field, contains, linksTo } from "https://cardstack.com/base/card-api";
import StringField from "https://cardstack.com/base/string";

export class Pet extends CardDef {
@field name = contains(StringField);
@field bestFriend = linksTo(() => Pet);
@field cardTitle = contains(StringField, {
computeVia: function (this: Pet) {
return this.name;
},
});
}
`,
],
[
'Pet/mango.json',
JSON.stringify({
data: {
attributes: {
name: 'Mango',
},
meta: {
adoptsFrom: {
module: '../pet.gts',
name: 'Pet',
},
},
},
}),
],
[
'Pet/vangogh.json',
JSON.stringify({
data: {
attributes: {
name: 'Van Gogh',
},
relationships: {
bestFriend: {
links: {
self: './mango',
},
},
},
meta: {
adoptsFrom: {
module: '../pet.gts',
name: 'Pet',
},
},
},
}),
],
]);

await realm.writeMany(writes);

let response = await request
.get('/Pet/vangogh')
.set('Accept', 'application/vnd.card+json');

assert.strictEqual(
response.status,
200,
`HTTP 200 status: ${response.text}`,
);

let doc = response.body as SingleCardDocument;

assert.strictEqual(
doc.data.id,
`${prefixForTest}Pet/vangogh`,
'card ID is in prefix form',
);

let bestFriendRel = doc.data.relationships
?.bestFriend as Relationship;
assert.ok(bestFriendRel, 'bestFriend relationship exists');
assert.strictEqual(
(bestFriendRel.data as any)?.id,
`${prefixForTest}Pet/mango`,
'relationship data.id is in prefix form',
);
});
});

// using public writable realm to make it easy for test setup for the error tests
module('public writable realm', function (hooks) {
setupPermissionedRealmCached(hooks, {
Expand Down
193 changes: 165 additions & 28 deletions packages/realm-server/tests/card-reference-resolver-test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,95 @@
import { module, test } from 'qunit';
import { basename } from 'path';
import { registerCardReferencePrefix } from '@cardstack/runtime-common';
import {
registerCardReferencePrefix,
unregisterCardReferencePrefix,
resolveCardReference,
} from '@cardstack/runtime-common';
import type { SingleCardDocument } from '@cardstack/runtime-common';
import { relativizeDocument } from '@cardstack/runtime-common/realm-index-query-engine';

// Regression test for CS-10498: cards in prefix-mapped realms (like the
// openrouter realm) threw TypeError: Invalid URL when served.
//
// After the import-maps change, unresolveResourceInstanceURLs converts
// card IDs in the index to prefix form (e.g. "@cardstack/openrouter/...").
// relativizeResource then used the raw prefix string as a URL base for
// resolveCardReference, causing new URL() to throw when resolving
// relative module deps like "../openrouter-model".
//
// The fix uses cardIdToURL() to resolve the prefix to a real URL first.

module(basename(__filename), function () {
module('card reference resolver', function () {
test('relativizeDocument succeeds when resource ID is a registered prefix', async function (assert) {
registerCardReferencePrefix(
'@test-cs10498/',
'http://test-host/my-realm/',
module('resolveCardReference', function (hooks) {
let prefix1 = '@test-ref/skills/';
let prefix2 = '@test-ref/catalog/';

hooks.beforeEach(function () {
registerCardReferencePrefix(prefix1, 'http://localhost:9000/skills/');
registerCardReferencePrefix(prefix2, 'http://localhost:9000/catalog/');
});

hooks.afterEach(function () {
unregisterCardReferencePrefix(prefix1);
unregisterCardReferencePrefix(prefix2);
});

test('resolves a prefix-mapped reference', async function (assert) {
assert.strictEqual(
resolveCardReference('@test-ref/skills/Skill/foo', undefined),
'http://localhost:9000/skills/Skill/foo',
);
});

test('resolves a relative URL with a normal URL base', async function (assert) {
assert.strictEqual(
resolveCardReference(
'./foo.md',
'http://localhost:9000/skills/Skill/bar',
),
'http://localhost:9000/skills/Skill/foo.md',
);
});

test('resolves an absolute https URL when relativeTo is a prefix-form ID', async function (assert) {
assert.strictEqual(
resolveCardReference(
'https://example.com/card/123',
'@test-ref/skills/Skill/foo',
),
'https://example.com/card/123',
);
});

test('resolves a relative URL when relativeTo is a prefix-form ID', async function (assert) {
assert.strictEqual(
resolveCardReference('./foo.md', '@test-ref/skills/Skill/bar'),
'http://localhost:9000/skills/Skill/foo.md',
);
});

// Build a SingleCardDocument that mirrors what the index returns for
// a card in a prefix-mapped realm:
// - links.self is a full URL (set by cardDocument)
// - data.id is in prefix form (set by unresolveResourceInstanceURLs)
// - meta.adoptsFrom.module is a relative URL (from serialization)
test('resolves a relative URL when relativeTo is a different prefix-form ID', async function (assert) {
assert.strictEqual(
resolveCardReference(
'./Component',
'@test-ref/catalog/components/Card',
),
'http://localhost:9000/catalog/components/Component',
);
});

test('throws for an unregistered bare specifier', async function (assert) {
assert.throws(
() => resolveCardReference('unknown-pkg/foo', undefined),
/Cannot resolve bare package specifier "unknown-pkg\/foo"/,
);
});
});

module('relativizeDocument with prefix-form IDs', function (hooks) {
let prefix = '@test-rel/realm/';

hooks.beforeEach(function () {
registerCardReferencePrefix(prefix, 'http://test-host/my-realm/');
});

hooks.afterEach(function () {
unregisterCardReferencePrefix(prefix);
});

test('succeeds when resource ID is a registered prefix', async function (assert) {
let doc: SingleCardDocument = {
data: {
id: '@test-cs10498/Card/my-instance',
id: '@test-rel/realm/Card/my-instance',
type: 'card' as const,
attributes: { name: 'Test' },
relationships: {},
Expand All @@ -46,10 +105,6 @@ module(basename(__filename), function () {

let realmURL = new URL('http://test-host/my-realm/');

// This is the exact call that getCard → cardDocument makes.
// Without the fix, it throws TypeError: Invalid URL because
// relativizeResource passes the prefix-form data.id directly
// as a URL base to resolveCardReference.
try {
relativizeDocument(doc, realmURL);
assert.ok(
Expand All @@ -64,7 +119,89 @@ module(basename(__filename), function () {
}
});

test('relativizeDocument succeeds when resource ID is a regular URL', async function (assert) {
test('resolves linksTo relationship with relative URL and prefix-form resource ID', async function (assert) {
// This mirrors the real bug: a SkillPlusMarkdown card at
// @cardstack/skills/Skill/source-code-editing has a linksTo
// relationship with links.self = "./source-code-editing.md".
// processRelationships calls resolveCardReference("./source-code-editing.md",
// "@cardstack/skills/Skill/source-code-editing") which must resolve
// the prefix-form base before resolving the relative URL.
let doc: SingleCardDocument = {
data: {
id: '@test-rel/realm/Skill/my-skill',
type: 'card' as const,
attributes: { name: 'My Skill' },
relationships: {
instructionsSource: {
links: {
self: './my-skill.md',
},
},
},
links: { self: 'http://test-host/my-realm/Skill/my-skill' },
meta: {
adoptsFrom: {
module: '../skill',
name: 'Skill',
},
},
},
};

let realmURL = new URL('http://test-host/my-realm/');
relativizeDocument(doc, realmURL);

// The relationship link should be relativized correctly
let rel = doc.data.relationships?.instructionsSource as any;
assert.ok(rel, 'relationship exists after relativization');
assert.ok(
rel.links.self,
'relationship links.self is preserved (not thrown)',
);
});

test('resolves linksTo relationship with absolute URL and prefix-form resource ID', async function (assert) {
// This mirrors the bug where a card at @cardstack/skills/Skill/boxel-environment
// has a relationship pointing to https://cardstack.com/base/Theme/brand-guide.
// processRelationships calls resolveCardReference(
// "https://cardstack.com/base/Theme/brand-guide",
// "@cardstack/skills/Skill/boxel-environment"
// ) which must handle the absolute URL without using the prefix-form base.
let doc: SingleCardDocument = {
data: {
id: '@test-rel/realm/Skill/env',
type: 'card' as const,
attributes: { name: 'Environment' },
relationships: {
theme: {
links: {
self: 'https://cardstack.com/base/Theme/brand-guide',
},
},
},
links: { self: 'http://test-host/my-realm/Skill/env' },
meta: {
adoptsFrom: {
module: '../skill',
name: 'Skill',
},
},
},
};

let realmURL = new URL('http://test-host/my-realm/');
relativizeDocument(doc, realmURL);

let rel = doc.data.relationships?.theme as any;
assert.ok(rel, 'relationship exists after relativization');
assert.strictEqual(
rel.links.self,
'https://cardstack.com/base/Theme/brand-guide',
'absolute URL to another realm is preserved as-is',
);
});

test('succeeds when resource ID is a regular URL', async function (assert) {
let doc: SingleCardDocument = {
data: {
id: 'http://test-host/my-realm/Card/my-instance',
Expand Down
17 changes: 17 additions & 0 deletions packages/runtime-common/card-reference-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export function registerCardReferencePrefix(
prefixMappings.set(prefix, targetURL);
}

export function unregisterCardReferencePrefix(prefix: string): void {
prefixMappings.delete(prefix);
}

export function isRegisteredPrefix(reference: string): boolean {
for (let [prefix] of prefixMappings) {
if (reference.startsWith(prefix)) {
Expand Down Expand Up @@ -39,6 +43,19 @@ export function resolveCardReference(
`Cannot resolve bare package specifier "${reference}" — no matching prefix mapping registered`,
);
}
if (reference.startsWith('http://') || reference.startsWith('https://')) {
return new URL(reference).href;
}
// If relativeTo is a prefix-form ID (e.g. @cardstack/skills/Foo/bar),
// resolve it to a real URL before using it as a base.
if (typeof relativeTo === 'string') {
for (let [prefix, target] of prefixMappings) {
if (relativeTo.startsWith(prefix)) {
relativeTo = new URL(relativeTo.slice(prefix.length), target).href;
break;
}
}
}
return new URL(reference, relativeTo).href;
}

Expand Down
Loading
Loading