Skip to content
Merged
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
36 changes: 22 additions & 14 deletions packages/host/app/components/realm-picker/index.gts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] onChange still does new URL(opt.id), and opt.id is a realm identifier straight from availableRealmIdentifiers — so this component's hard failure is untouched while its soft one is fixed. A prefix reaching the label path now yields the realm's name; the same prefix reaching a click throws TypeError: Invalid URL and takes the picker's selection with it.

The lint rule can't reach it: PickerOption.id is plain string, so the RealmIdentifier brand is gone the moment the identifier is stored into an option and read back. That makes green lint no evidence for this file.

Resolving at the parse point isn't enough either, because the two ends mint ids in different spellings: realmOptions uses the identifier, pickerSelected uses url.href, and Picker matches selection by o.id === option.id (packages/boxel-ui/src/components/picker/index.gts). With a prefix registered, a selected realm would come back as an option that never renders selected.

Both fall out if the ids are minted resolved — inject the network service and build realmOptions ids as vn.toURL(identifier).href. Then every opt.id is URL-form, new URL(opt.id) is safe, and the option/selected spellings agree.

Class: pre-existing, not introduced here — but it is in this file and inside the PR's own stated scope, and it is the failure that throws. Non-blocking only if you'd rather split it; I'd take it here.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Taken here. realmOptions now mints ids through resolvedRealmURLHref, so every opt.id is URL-form: onChange's parse is safe, and the option/selected spellings agree so Picker's id comparison can't miss. Label and realm.info() still read the identifier as given — info() is form-agnostic already, and realmIdentifierSegments wants the identifier, not the mount path.

Confirmed no consumer depends on the old spelling: onChange hands out URL[], and selectedURLs is minted by the parent rather than from option ids.

Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import { cached } from '@glimmer/tracking';

import { Picker, type PickerOption } from '@cardstack/boxel-ui/components';

import type NetworkService from '@cardstack/host/services/network';
import type RealmService from '@cardstack/host/services/realm';
import type RealmServerService from '@cardstack/host/services/realm-server';

import {
realmIdentifierSegments,
resolvedRealmURLHref,
} from '../../lib/realm-utils';
import WithKnownRealmsLoaded from '../with-known-realms-loaded';

export interface RealmFilter {
Expand All @@ -29,6 +34,7 @@ interface Signature {
}

export default class RealmPicker extends Component<Signature> {
@service declare private network: NetworkService;
@service declare realm: RealmService;
@service declare realmServer: RealmServerService;

Expand Down Expand Up @@ -62,14 +68,21 @@ export default class RealmPicker extends Component<Signature> {
}

get realmOptions(): PickerOption[] {
const urls = this.realmServer.availableRealmIdentifiers;
const identifiers = this.realmServer.availableRealmIdentifiers;
const options: PickerOption[] = [this.selectAllOption];
for (const realmURL of urls) {
const info = this.realm.info(realmURL);
const label = info?.name ?? this.realmDisplayNameFromURL(realmURL);
for (const identifier of identifiers) {
const info = this.realm.info(identifier);
const label = info?.name ?? this.realmDisplayNameFromURL(identifier);
const icon = info?.iconURL ?? undefined;
options.push({
id: realmURL,
// Minted resolved, because an option id has to survive two things a
// realm identifier does not: `Picker` decides which option is selected
// by comparing ids against `pickerSelected`, which mints its own from
// `URL.href`, and `onChange` parses the id back into a `URL`. Both need
// the two ends to spell a realm the same way, and only one of the two
// ends gets to choose. `PickerOption.id` is a plain string, so nothing
// downstream can tell that a prefix ever arrived.
id: resolvedRealmURLHref(this.network.virtualNetwork, identifier),
icon,
label,
type: 'option',
Expand All @@ -86,16 +99,11 @@ export default class RealmPicker extends Component<Signature> {
};

private realmDisplayNameFromURL(realmURL: string): string {
try {
const pathname = new URL(realmURL).pathname;
const segments = pathname.split('/').filter(Boolean);
if (segments.length === 0) {
return 'Base';
}
return segments[segments.length - 1] ?? 'Base';
} catch {
return 'Unknown Workspace';
const segments = realmIdentifierSegments(realmURL);
if (segments.length === 0) {
return 'Base';
}
return segments[segments.length - 1] ?? 'Base';
}

<template>
Expand Down
58 changes: 58 additions & 0 deletions packages/host/app/lib/realm-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
isUrlLike,
RealmPaths,
ri,
rri,
Expand Down Expand Up @@ -52,3 +53,60 @@ export function resolveCardRealmUrl(
}
return new RealmPaths(virtualNetwork.toURL(cardId)).url;
}

/**
* A realm identifier as a URL href, whichever form it arrives in.
*
* `virtualNetwork.toURL` already resolves a registered prefix and parses a URL
* form unchanged, so it covers both spellings on its own. What it does not do
* is tolerate an identifier that is neither — an unregistered prefix, a bare
* local id — where it throws. The callers here are display and lookup paths
* that must not fail a render over an identifier they cannot place, so an
* unresolvable identifier comes back untouched and the caller's existing
* handling for an unplaceable realm applies.
*
* @example
* // with '@cardstack/base/' mapped to 'https://realms.example.com/base/'
* resolvedRealmURLHref(vn, '@cardstack/base/') // 'https://realms.example.com/base/'
* resolvedRealmURLHref(vn, 'https://x.test/r/') // 'https://x.test/r/'
* resolvedRealmURLHref(vn, '@unmapped/thing/') // '@unmapped/thing/'
*/
export function resolvedRealmURLHref(
virtualNetwork: VirtualNetwork,
realmIdentifier: string,
): string {
return virtualNetwork.isRegisteredPrefix(realmIdentifier)
? virtualNetwork.toURL(realmIdentifier).href
: realmIdentifier;
}

/**
* The segments that *name* a realm, in whichever form its identifier is
* expressed. For naming a realm — a label, a heading — not for locating it.
*
* A realm identifier is either a URL (`https://host/foo/bar/`) or a registered
* prefix (`@scope/name/`). Only the first has a pathname to take apart, so
* deriving segments by parsing loses the prefix form — silently, if the parse
* sits in a `try`. Both forms end in the segment that names the realm, which
* is what a label is after.
*
* The two forms do NOT agree on where a realm is mounted: `@cardstack/base/`
* names two segments while the realm it maps to is served at `/base/`. Anything
* matching a request path against a realm must resolve the identifier through
* `virtualNetwork.toURL()` and use that pathname instead.
*
* @example
* realmIdentifierSegments('https://cardstack.com/base/') // ['base']
* realmIdentifierSegments('@cardstack/base/') // ['@cardstack', 'base']
* realmIdentifierSegments('https://example.com/') // []
*/
export function realmIdentifierSegments(realmIdentifier: string): string[] {
if (!isUrlLike(realmIdentifier)) {
return realmIdentifier.split('/').filter(Boolean);
}
try {
return new URL(realmIdentifier).pathname.split('/').filter(Boolean);
} catch {
return [];
}
}
37 changes: 22 additions & 15 deletions packages/host/app/routes/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import { isFileDefInstance } from '@cardstack/runtime-common/code-ref';

import { Submodes } from '@cardstack/host/components/submode-switcher';
import ENV from '@cardstack/host/config/environment';
import { resolvedRealmURLHref } from '@cardstack/host/lib/realm-utils';
import type { StackItemType } from '@cardstack/host/lib/stack-item';

import type BillingService from '@cardstack/host/services/billing-service';
import type CardService from '@cardstack/host/services/card-service';
import type HostModeService from '@cardstack/host/services/host-mode-service';
import type HostModeStateService from '@cardstack/host/services/host-mode-state-service';
import type MatrixService from '@cardstack/host/services/matrix-service';
import type NetworkService from '@cardstack/host/services/network';
import type OperatorModeStateService from '@cardstack/host/services/operator-mode-state-service';
import type { SerializedState as OperatorModeSerializedState } from '@cardstack/host/services/operator-mode-state-service';
import type RealmService from '@cardstack/host/services/realm';
Expand Down Expand Up @@ -59,6 +61,7 @@ export default class Card extends Route {
@service declare private operatorModeStateService: OperatorModeStateService;
@service declare private router: RouterService;
@service declare private store: StoreService;
@service declare private network: NetworkService;
@service declare realm: RealmService;
@service declare realmServer: RealmServerService;

Expand Down Expand Up @@ -292,15 +295,18 @@ export default class Card extends Route {
let cardUrl;
if (hostsOwnAssets) {
// availableRealmIdentifiers is set in matrixService.start(), so we can use it here
let realmUrl = this.realmServer.availableRealmIdentifiers.find(
(realmUrl) => {
// `availableRealmIdentifiers` is seeded from `baseRealm.url` and from
// realm-server responses, so entries are URL-form and this does not
// throw. If prefix forms reach here, this needs to compare namespaces
// rather than pathnames — there is no path to take apart in
// `@scope/name/`.
// eslint-disable-next-line @cardstack/boxel/no-url-from-realm-identifier
let realmPathParts = new URL(realmUrl).pathname
// The question here is which realm *serves* this card path, so the
// comparison is against each realm's mounted URL path. A registered
// prefix does not carry one: `@cardstack/base/` names two namespace
// segments while the realm it maps to is mounted at `/base/`, so
// comparing the prefix directly matches nothing. Resolve first, then
// match — and the match doubles as the base below, which `new URL`
// accepts only in URL form.
let vn = this.network.virtualNetwork;
let realmUrl = this.realmServer.availableRealmIdentifiers
.map((identifier) => resolvedRealmURLHref(vn, identifier))
.find((resolvedRealmUrl) => {
let realmPathParts = new URL(resolvedRealmUrl).pathname
.split('/')
.filter((part) => part !== '');
let cardPathParts = cardPath!
Expand All @@ -316,14 +322,15 @@ export default class Card extends Route {
}
}
return isMatch;
},
);
// The base is a realm identifier from the same list read above. No form
// guard, and reachability by a prefix form is unverified.
// eslint-disable-next-line @cardstack/boxel/no-url-from-realm-identifier
});
// The fallback is a realm identifier as well: `defaultReadableRealm.path`
// is a key of `realm.realms`, which is keyed by whatever spelling created
// each resource, or else the configured base realm URL. So it gets the
// same resolution as the entries above rather than being assumed a URL.
cardUrl = new URL(
`/${cardPath}`,
realmUrl ?? this.realm.defaultReadableRealm.path,
realmUrl ??
resolvedRealmURLHref(vn, this.realm.defaultReadableRealm.path),
).href;
} else {
cardUrl = new URL(cardPath, window.location.origin).href;
Expand Down
23 changes: 21 additions & 2 deletions packages/host/app/services/realm-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from '@cardstack/runtime-common/realm-auth-client';

import ENV from '@cardstack/host/config/environment';
import { resolvedRealmURLHref } from '@cardstack/host/lib/realm-utils';
import {
RealmServerSessionLocalStorageKey,
SessionLocalStorageKey,
Expand Down Expand Up @@ -517,13 +518,31 @@ export default class RealmServerService extends Service {

for (let realmURL of realms) {
let normalizedRealmURL = ensureTrailingSlash(realmURL);
// A realm identifier may be a registered prefix, which neither carries an
// origin to compare nor matches the URL a session token is filed under.
// Resolve once and let both questions below ask in URL form, so the
// function is form-agnostic end to end rather than only at the skip.
let resolvedRealmURL = resolvedRealmURLHref(
this.network.virtualNetwork,
normalizedRealmURL,
);
// Anything resolving to the test realm's origin is served by the test
// realm server and skipped.
if (
testRealmOrigin &&
new URL(normalizedRealmURL).origin === testRealmOrigin
new URL(resolvedRealmURL).origin === testRealmOrigin
) {
continue;
}
let token = sessionTokens[normalizedRealmURL] ?? sessionTokens[realmURL];
// A token is filed under the realm resource's own url, which is whatever
// spelling created the resource — so try the identifier as given and its
// resolved form. Missing the token does not fail loudly: the loop would
// `continue`, and a realm that is the only entry would leave the set
// empty and answer with this realm server instead of the realm's.
let token =
sessionTokens[normalizedRealmURL] ??
sessionTokens[realmURL] ??
sessionTokens[resolvedRealmURL];
if (!token) {
continue;
}
Expand Down
121 changes: 121 additions & 0 deletions packages/host/tests/integration/realm-server-form-agnostic-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { getService } from '@universal-ember/test-support';
import window from 'ember-window-mock';
import { module, test } from 'qunit';

import { baseRealm } from '@cardstack/runtime-common';

import { SessionLocalStorageKey } from '@cardstack/host/utils/local-storage-keys';

import {
testRealmURL,
setupIntegrationTestRealm,
setupLocalIndexing,
} from '../helpers';
import { setupBaseRealm } from '../helpers/base-realm';
import { setupMockMatrix } from '../helpers/mock-matrix';
import { setupRenderingTest } from '../helpers/setup';

// `window` here is ember-window-mock's, the same one the service reads its
// session tokens through — `setupRenderingTest` installs it. Writing to the
// real global instead would leave the service seeing no tokens at all.
//
// A realm mapped to an origin of its own, so the resolved URL is not the test
// realm's — `getRealmServersForRealms` skips anything at the test realm origin,
// which would hide the token lookup this exercises.
const MAPPED_REALM_URL = 'https://mapped-realm.example.com/realm/';
const REALM_SERVER_URL = 'https://mapped-server.example.com/';
const PREFIX = '@form-agnostic-test/';

// A session token is only ever read for its claims here, never verified, so a
// header-and-payload pair is the whole shape that matters.
function sessionToken(claims: Record<string, unknown>): string {
return `header.${btoa(JSON.stringify(claims))}`;
}

// `getRealmServersForRealms` answers "which realm server serves these realms?"
// by looking each realm's session token up by its identifier. A registered
// prefix is a realm identifier too, and it matches no token key — so without
// resolution the lookup misses, the loop skips the realm, and an empty result
// set makes the function answer with *this* realm server rather than the
// realm's. That is a wrong answer returned quietly, which is what these pin.
module('Integration | realm-server | identifier forms', function (hooks) {
setupRenderingTest(hooks);
setupLocalIndexing(hooks);

let mockMatrixUtils = setupMockMatrix(hooks, {
loggedInAs: '@testuser:localhost',
activeRealms: [baseRealm.url, testRealmURL],
autostart: true,
});

setupBaseRealm(hooks);

hooks.beforeEach(async function () {
await setupIntegrationTestRealm({ mockMatrixUtils, contents: {} });
});

hooks.afterEach(function () {
window.localStorage.removeItem(SessionLocalStorageKey);
getService('network').virtualNetwork.removeRealmMapping(PREFIX);
});

test('finds a realm server for a realm named by a URL identifier', function (assert) {
window.localStorage.setItem(
SessionLocalStorageKey,
JSON.stringify({
[MAPPED_REALM_URL]: sessionToken({ realmServerURL: REALM_SERVER_URL }),
}),
);

let realmServer = getService('realm-server');
assert.deepEqual(
realmServer.getRealmServersForRealms([MAPPED_REALM_URL]),
[REALM_SERVER_URL],
'the URL form finds its token and reports the realm’s own server',
);
});

test('finds a realm server for a realm named by a registered prefix', function (assert) {
// The token is filed under the realm's URL, which is how a realm resource
// that logged in with a URL files it — the prefix has to resolve to reach it.
getService('network').virtualNetwork.addRealmMapping(
PREFIX,
MAPPED_REALM_URL,
);
window.localStorage.setItem(
SessionLocalStorageKey,
JSON.stringify({
[MAPPED_REALM_URL]: sessionToken({ realmServerURL: REALM_SERVER_URL }),
}),
);

let realmServer = getService('realm-server');
assert.deepEqual(
realmServer.getRealmServersForRealms([PREFIX]),
[REALM_SERVER_URL],
'the prefix form resolves to the same token and the same server',
);
});

test('a prefix whose token is filed under the prefix is still found', function (assert) {
// The other direction: a realm resource created from the prefix files its
// token under the prefix, so the unresolved spelling has to work too.
getService('network').virtualNetwork.addRealmMapping(
PREFIX,
MAPPED_REALM_URL,
);
window.localStorage.setItem(
SessionLocalStorageKey,
JSON.stringify({
[PREFIX]: sessionToken({ realmServerURL: REALM_SERVER_URL }),
}),
);

let realmServer = getService('realm-server');
assert.deepEqual(
realmServer.getRealmServersForRealms([PREFIX]),
[REALM_SERVER_URL],
'both spellings are accepted as token keys',
);
});
});
Loading
Loading