diff --git a/packages/realm-server/tests/card-endpoints-test.ts b/packages/realm-server/tests/card-endpoints-test.ts index 7ea55494ed2..dd5dffd4377 100644 --- a/packages/realm-server/tests/card-endpoints-test.ts +++ b/packages/realm-server/tests/card-endpoints-test.ts @@ -2576,6 +2576,91 @@ module(basename(import.meta.filename), function () { let { getMessagesSince } = setupMatrixRoom(hooks, getRealmSetup); + // What is stored for a relationship depends on whether the link can be + // relativized against the writing realm. A scoped reference cannot be, + // and must not be resolved either: resolving one stores whatever URL + // the linked realm answers to in this environment, so a realm under + // version control ends up carrying a dev or staging host. + test('stores a scoped cross-realm link verbatim', async function (assert) { + let scopedLink = '@cardstack/catalog/Pet/vangogh'; + + let response = await request + .patch('/hassan') + .send({ + data: { + type: 'card', + relationships: { + friend: { links: { self: scopedLink } }, + }, + meta: { + adoptsFrom: { + module: rri('./friend.gts'), + name: 'Friend', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + + assert.strictEqual( + response.status, + 200, + `HTTP 200 status: ${response.text}`, + ); + + let cardFile = join( + dir.name, + 'realm_server_1', + 'test', + 'hassan.json', + ); + let stored = JSON.parse(readFileSync(cardFile, 'utf8')); + assert.strictEqual( + stored.data.relationships.friend.links.self, + scopedLink, + 'the stored link is the scoped reference the client sent', + ); + }); + + test('still relativizes a same-realm link', async function (assert) { + let response = await request + .patch('/hassan') + .send({ + data: { + type: 'card', + relationships: { + friend: { links: { self: `${testRealmHref}jade` } }, + }, + meta: { + adoptsFrom: { + module: rri('./friend.gts'), + name: 'Friend', + }, + }, + }, + }) + .set('Accept', 'application/vnd.card+json'); + + assert.strictEqual( + response.status, + 200, + `HTTP 200 status: ${response.text}`, + ); + + let cardFile = join( + dir.name, + 'realm_server_1', + 'test', + 'hassan.json', + ); + let stored = JSON.parse(readFileSync(cardFile, 'utf8')); + assert.strictEqual( + stored.data.relationships.friend.links.self, + './jade', + 'an in-realm link is still stored relative', + ); + }); + test('serves the request', async function (assert) { let entry = 'person-1.json'; diff --git a/packages/realm-server/tests/stored-relationship-link-test.ts b/packages/realm-server/tests/stored-relationship-link-test.ts new file mode 100644 index 00000000000..6bc6daf606b --- /dev/null +++ b/packages/realm-server/tests/stored-relationship-link-test.ts @@ -0,0 +1,96 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import { basename } from 'path'; +import type { VirtualNetwork } from '@cardstack/runtime-common/virtual-network'; +import { storedRelationshipLink } from '@cardstack/runtime-common/file-serializer'; + +const realmURL = new URL('http://test/realm/'); +const relativeTo = new URL('http://test/realm/hassan.json'); +const ownPrefix = '@cardstack/realm/'; +const otherPrefix = '@cardstack/catalog/'; +const otherRealm = 'http://test/catalog/'; + +// A VirtualNetwork stand-in that maps one prefix onto the realm under test and +// another onto a second realm, so a scoped reference can point either inside +// or outside the writing realm — the distinction the helper exists to draw. +function makeStubNetwork(): VirtualNetwork { + let targets: [string, string][] = [ + [ownPrefix, realmURL.href], + [otherPrefix, otherRealm], + ]; + return { + isRegisteredPrefix(reference: string) { + return targets.some(([prefix]) => reference.startsWith(prefix)); + }, + resolveURL(reference: string, base: URL | string | undefined) { + for (let [prefix, target] of targets) { + if (reference.startsWith(prefix)) { + return new URL(reference.slice(prefix.length), target); + } + } + return new URL(reference, base ?? undefined); + }, + } as unknown as VirtualNetwork; +} + +function stored(selfLink: string): string { + return storedRelationshipLink( + selfLink, + relativeTo, + realmURL, + makeStubNetwork(), + ); +} + +module(basename(import.meta.filename), function () { + module('storedRelationshipLink', function () { + test('stores an in-realm URL relative', function (assert) { + assert.strictEqual(stored('http://test/realm/jade'), './jade'); + }); + + test('stores an in-realm scoped reference relative', function (assert) { + // Regression: passing a scoped reference straight to + // `maybeRelativeReference` returns it unchanged, because the pair is + // mixed RRI/URL. Resolving first is what makes this relative. + assert.strictEqual(stored(`${ownPrefix}jade`), './jade'); + }); + + test('stores a cross-realm scoped reference exactly as sent', function (assert) { + assert.strictEqual( + stored(`${otherPrefix}Pet/vangogh`), + `${otherPrefix}Pet/vangogh`, + ); + }); + + // The case the write suite caught and this stub originally hid: the stub + // registered every prefix it was asked about, while the realm-server + // harness registers only `@cardstack/base/`. Resolving an unregistered + // scoped reference joins it against the base as though it were relative. + test('stores an unregistered scoped reference exactly as sent', function (assert) { + assert.strictEqual( + stored('@nobody/knows-this/Pet/vangogh'), + '@nobody/knows-this/Pet/vangogh', + ); + }); + + test('stores a cross-realm URL resolved', function (assert) { + assert.strictEqual( + stored('http://elsewhere/other/Pet/vangogh'), + 'http://elsewhere/other/Pet/vangogh', + ); + }); + + test('leaves a reference that will not resolve alone', function (assert) { + let network = { + isRegisteredPrefix: () => false, + resolveURL() { + throw new Error('nope'); + }, + } as unknown as VirtualNetwork; + assert.strictEqual( + storedRelationshipLink('::not a url::', relativeTo, realmURL, network), + '::not a url::', + ); + }); + }); +}); diff --git a/packages/runtime-common/file-serializer.ts b/packages/runtime-common/file-serializer.ts index 4f620d23888..f73fabb5162 100644 --- a/packages/runtime-common/file-serializer.ts +++ b/packages/runtime-common/file-serializer.ts @@ -17,7 +17,10 @@ import type { VirtualNetwork } from './virtual-network.ts'; import { isMeta, type CardFields, type Meta } from './resource-types.ts'; import type { DefinitionLookup } from './definition-lookup.ts'; import { serialize as serializeCodeRef } from './serializers/code-ref.ts'; -import { maybeRelativeReference as makeRelativeReference } from './url.ts'; +import { + isScopedReference, + maybeRelativeReference as makeRelativeReference, +} from './url.ts'; export default async function serialize({ doc, @@ -319,6 +322,57 @@ async function resolveChildDef( return await definitionLookup.lookupDefinition(codeRef); } +// What a relationship's `links.self` should be stored as. +// +// Inside the writing realm a link is stored relative, whichever form the +// client sent. Outside it, a scoped reference is stored exactly as sent and +// anything else is stored resolved. +// +// Whether a link is inside the realm is decided in URL space, so the link is +// resolved before the question is asked: `relativeTo` is always a URL here, +// and `relativeReference` refuses a mixed RRI/URL pair rather than resolve +// across forms — handing it a scoped reference returns that reference +// unchanged, which looks like "cannot be relativized" even for a link into +// this very realm. +// +// Storing a resolved URL for a cross-realm scoped link is the bug this exists +// to prevent: it bakes whatever URL the linked realm answers to in this +// environment into a realm that is version controlled. +export function storedRelationshipLink( + selfLink: string, + relativeTo: URL, + realmURL: URL, + virtualNetwork: VirtualNetwork, +): string { + // A scoped reference whose prefix this process has not registered cannot be + // resolved, and asking anyway is worse than not asking: `resolveURL` treats + // it as a relative reference and joins it against the base, so + // `@scope/name/x` comes back as a URL *inside* the writing realm and then + // relativizes to `./@scope/name/x`. It is absolute and cross-realm by + // construction, so store it exactly as sent. + if ( + !virtualNetwork.isRegisteredPrefix(selfLink) && + selfLink.startsWith('@') + ) { + return selfLink; + } + let resolved: URL; + try { + resolved = virtualNetwork.resolveURL(selfLink, relativeTo); + } catch (e) { + // A reference that will not resolve is left exactly as it arrived. + return selfLink; + } + let relative = makeRelativeReference(resolved, relativeTo, realmURL); + if ( + relative === resolved.href && + isScopedReference(selfLink, virtualNetwork) + ) { + return selfLink; + } + return relative; +} + async function processRelationships({ relationships, definition, @@ -346,15 +400,12 @@ async function processRelationships({ if (processedValue.links.self !== null) { let selfLink = processedValue.links.self; if (realmURL && selfLink) { - try { - selfLink = makeRelativeReference( - virtualNetwork.resolveURL(selfLink, relativeTo), - relativeTo, - realmURL, - ); - } catch (e) { - // ignore malformed URLs and leave as-is - } + selfLink = storedRelationshipLink( + selfLink, + relativeTo, + realmURL, + virtualNetwork, + ); } processedValue.links = { self: selfLink, diff --git a/packages/runtime-common/realm-index-query-engine.ts b/packages/runtime-common/realm-index-query-engine.ts index d33acaac341..bd09218cd8c 100644 --- a/packages/runtime-common/realm-index-query-engine.ts +++ b/packages/runtime-common/realm-index-query-engine.ts @@ -26,6 +26,7 @@ import { internalKeyFor, visitInstanceURLs, maybeRelativeReference, + isScopedReference, codeRefFromInternalKey, } from './index.ts'; import type { Realm } from './realm.ts'; @@ -2194,19 +2195,6 @@ export function relativizeDocument( } } -// A reference in scoped RRI form (e.g. `@cardstack/base/card-api`) is an -// absolute cross-realm identifier and must never be treated as realm-relative. -// Registered prefixes are a subset, but a scoped reference to a realm this -// VirtualNetwork does not know is still scoped — the leading `@` is the signal. -function isScopedReference( - reference: string, - virtualNetwork: VirtualNetwork, -): boolean { - return ( - reference.startsWith('@') || virtualNetwork.isRegisteredPrefix(reference) - ); -} - function relativizeResource( resource: LooseCardResource, primaryURL: URL, diff --git a/packages/runtime-common/url.ts b/packages/runtime-common/url.ts index 0a76ef69282..7ecd02a296b 100644 --- a/packages/runtime-common/url.ts +++ b/packages/runtime-common/url.ts @@ -148,6 +148,25 @@ const RRI_SYNTHETIC_ORIGIN = 'https://rri.invalid'; // resolving the realm root requires realm mappings — `resolveRRI` likewise // has no realm root to resolve against there.) // Falls back to the reference unchanged when there is no usable base. +// A reference in scoped RRI form (e.g. `@cardstack/base/card-api`) is an +// absolute cross-realm identifier and must never be treated as realm-relative. +// Registered prefixes are a subset, but a scoped reference to a realm this +// VirtualNetwork does not know is still scoped — the leading `@` is the signal. +// +// Read and write share this one definition on purpose. Serving a document and +// storing one have to agree on which references are already canonical: when +// only the read path skipped them, a link the client sent as `@scope/name/x` +// was stored as whatever URL that realm happened to resolve to in the writing +// environment. +export function isScopedReference( + reference: string, + virtualNetwork: VirtualNetwork, +): boolean { + return ( + reference.startsWith('@') || virtualNetwork.isRegisteredPrefix(reference) + ); +} + export function resolveRRIReference( reference: string, relativeTo: RealmResourceIdentifier | URL | undefined,