Skip to content
Open
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
85 changes: 85 additions & 0 deletions packages/realm-server/tests/card-endpoints-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
96 changes: 96 additions & 0 deletions packages/realm-server/tests/stored-relationship-link-test.ts
Original file line number Diff line number Diff line change
@@ -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::',
);
});
});
});
71 changes: 61 additions & 10 deletions packages/runtime-common/file-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 1 addition & 13 deletions packages/runtime-common/realm-index-query-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
internalKeyFor,
visitInstanceURLs,
maybeRelativeReference,
isScopedReference,
codeRefFromInternalKey,
} from './index.ts';
import type { Realm } from './realm.ts';
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions packages/runtime-common/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading