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
102 changes: 89 additions & 13 deletions packages/host/app/services/matrix-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ export default class MatrixService extends Service {
@tracked private _client: ExtendedClient | undefined;
@tracked private _isInitializingNewUser = false;
@tracked private postLoginCompleted = false;
// When true, `app.boxel.realm-servers` is the authoritative source of
// the user's realm list and `app.boxel.realms` events are ignored for
// `setAvailableRealmIdentifiers`. Set during boot from whether that key
// has content, and flipped on by the realm-servers listener if the key
// gains content at runtime. Login-related side effects (`loginToRealms`,
// `loadMoreAuthRooms`) still run regardless.
private trustedRealmServersAuthoritative = false;
@tracked private _currentRoomId: string | undefined;
@tracked private timelineLoadingState: Map<string, boolean> =
new TrackedMap();
Expand Down Expand Up @@ -394,16 +401,57 @@ export default class MatrixService extends Service {
this.matrixSDK.ClientEvent.AccountData,
async (e) => {
switch (e.event.type) {
case APP_BOXEL_REALMS_EVENT_TYPE:
await this.realmServer.setAvailableRealmIdentifiers(
(e.event.content.realms as string[]).map(ri),
);
case APP_BOXEL_REALMS_EVENT_TYPE: {
let legacyRealms = e.event.content.realms as string[];
// When `app.boxel.realm-servers` is the source of truth,
// ignore the realm-list payload here — otherwise the
// initial-sync re-emission of this event would overwrite the
// trusted-servers boot result. Side effects below still run
// so post-login realm authentication isn't dropped.
if (!this.trustedRealmServersAuthoritative) {
await this.realmServer.setAvailableRealmIdentifiers(
legacyRealms.map(ri),
);
}
// Only do this after we've completed our overall login
if (this.postLoginCompleted) {
await this.loginToRealms();
await this.loadMoreAuthRooms(e.event.content.realms);
await this.loadMoreAuthRooms(legacyRealms);
}
break;
}
case APP_BOXEL_REALM_SERVERS_EVENT_TYPE: {
let realmServers = e.event.content.realmServers as string[];
this.trustedRealmServersAuthoritative = realmServers.length > 0;
if (this.trustedRealmServersAuthoritative) {
// A server-pushed account-data event must not crash the app:
// assembly can reject (e.g. fetchUserRealmsFromTrustedServers
// refuses a list that isn't this user's own realm server) and
// an async event handler that throws surfaces as an unhandled
// rejection. The authoritative, fail-loud assembly runs at
// start(); here we log and leave the available-realms list as
// it was.
try {
let realmURLs =
await this.realmServer.fetchUserRealmsFromTrustedServers(
realmServers,
);
await this.realmServer.setAvailableRealmIdentifiers(
realmURLs.map(ri),
);
if (this.postLoginCompleted) {
await this.loginToRealms();
await this.loadMoreAuthRooms(realmURLs);
}
} catch (err) {
console.error(
'Failed to assemble realms from trusted servers in app.boxel.realm-servers account data',
err,
);
}
}
break;
}
case APP_BOXEL_SYSTEM_CARD_EVENT_TYPE:
await this.setSystemCard(e.event.content.id);
break;
Expand Down Expand Up @@ -836,17 +884,47 @@ export default class MatrixService extends Service {
this.startedAtTs = 0;
}
if (isTesting())
console.warn('[start-phase] getAccountData(realms,favorites)');
let [accountDataContent, favoritesData] = await Promise.all([
console.warn('[start-phase] getAccountData(realm-servers,favorites)');
let [realmServersData, favoritesData] = await Promise.all([
this.client.getAccountDataFromServer(
APP_BOXEL_REALMS_EVENT_TYPE,
) as Promise<{ realms: string[] } | null>,
APP_BOXEL_REALM_SERVERS_EVENT_TYPE,
) as Promise<{ realmServers: string[] } | null>,
this.client.getAccountDataFromServer(
APP_BOXEL_WORKSPACE_FAVORITES_EVENT_TYPE,
) as Promise<{ favorites: string[] } | null>,
]);
this.workspaceFavorites = favoritesData?.favorites ?? [];

// Boot assembles the realm list from trusted servers via
// `_realm-auth`. The transition fallback below reads the legacy
// `app.boxel.realms` key when `app.boxel.realm-servers` is absent
// or empty, so users whose accounts haven't yet been migrated to
// `app.boxel.realm-servers` still boot. Remove the fallback once
// the lazy migration that populates `app.boxel.realm-servers` has
// run on all active accounts.
let trustedServers = realmServersData?.realmServers ?? [];
// The legacy `app.boxel.realms` AccountData event is re-emitted by
// the matrix sync that runs inside `startClient()` below. Setting
// this flag here makes that re-emission a no-op for the available-
// realms list — the realm-servers path is the authoritative source.
this.trustedRealmServersAuthoritative = trustedServers.length > 0;
let userRealmURLs: string[];
if (trustedServers.length > 0) {
if (isTesting())
console.warn('[start-phase] fetchUserRealmsFromTrustedServers');
userRealmURLs =
await this.realmServer.fetchUserRealmsFromTrustedServers(
trustedServers,
);
} else {
if (isTesting())
console.warn('[start-phase] getAccountData(realms-legacy)');
let legacyRealmsData = (await this.client.getAccountDataFromServer(
APP_BOXEL_REALMS_EVENT_TYPE,
)) as { realms: string[] } | null;
userRealmURLs = legacyRealmsData?.realms ?? [];
}

let noRealmsLoggedIn = Array.from(this.realm.realms.entries()).every(
([_url, realmResource]) => !realmResource.isLoggedIn,
);
Expand All @@ -857,9 +935,7 @@ export default class MatrixService extends Service {
);
await Promise.all([
this.realmServer.fetchCatalogRealms(),
this.realmServer.setAvailableRealmIdentifiers(
(accountDataContent?.realms ?? []).map(ri),
),
this.realmServer.setAvailableRealmIdentifiers(userRealmURLs.map(ri)),
]);

if (isTesting()) console.warn('[start-phase] prefetchRealmInfos');
Expand All @@ -868,7 +944,7 @@ export default class MatrixService extends Service {
);

if (isTesting()) console.warn('[start-phase] initSlidingSync');
await this.initSlidingSync(accountDataContent);
await this.initSlidingSync({ realms: userRealmURLs });
if (isTesting()) console.warn('[start-phase] startClient');
await this.client.startClient({ slidingSync: this.slidingSync });
if (isTesting())
Expand Down
42 changes: 42 additions & 0 deletions packages/host/app/services/realm-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,48 @@ export default class RealmServerService extends Service {
return response.json();
}

// Boot assembly reads `app.boxel.realm-servers` and asks each trusted
// server (via `_realm-auth`) which realms the current user has. Returns
// the union of realm URLs across all trusted servers. assertOwnRealmServer()
// keeps the single-server invariant — it rejects any list that includes a
// server other than the user's own until multi-realm-server federation
// ships.
async fetchUserRealmsFromTrustedServers(
trustedServerURLs: string[],
): Promise<string[]> {
if (trustedServerURLs.length === 0) {
return [];
}
// TODO: remove once multi-realm-server federation lands.
this.assertOwnRealmServer(trustedServerURLs);
await this.login();
let perServerRealmURLs = await Promise.all(
trustedServerURLs.map(async (serverURL) => {
let normalizedServerURL = ensureTrailingSlash(serverURL);
let response = await this.network.fetch(
`${normalizedServerURL}_realm-auth`,
{
method: 'POST',
headers: {
Accept: SupportedMimeType.JSONAPI,
'Content-Type': 'application/json',
Authorization: `Bearer ${this.token}`,
},
},
);
if (!response.ok) {
let responseText = await response.text();
throw new Error(
`Failed to fetch user realms from trusted server ${normalizedServerURL}: ${response.status} - ${responseText}`,
);
}
let tokens = (await response.json()) as Record<string, string>;
return Object.keys(tokens);
}),
);
return [...new Set(perServerRealmURLs.flat())];
}

@cached
get availableRealmIdentifiers(): RealmIdentifier[] {
return this.availableRealms.map((r) => ri(r.url));
Expand Down
184 changes: 184 additions & 0 deletions packages/host/tests/integration/matrix-service-boot-assembly-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type { RenderingTestContext } from '@ember/test-helpers';

import { getService } from '@universal-ember/test-support';
import { module, test } from 'qunit';

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

import ENV from '@cardstack/host/config/environment';
import type MatrixService from '@cardstack/host/services/matrix-service';
import type RealmServerService from '@cardstack/host/services/realm-server';

import {
testRealmURL,
setupIntegrationTestRealm,
setupLocalIndexing,
} from '../helpers';

import { setupBaseRealm } from '../helpers/base-realm';

import { setupMockMatrix } from '../helpers/mock-matrix';

import { setupRenderingTest } from '../helpers/setup';

const testRealmServerURL = ensureTrailingSlash(ENV.realmServerURL);

// Boot assembles the available-realms list from the user's trusted
// realm-servers (`app.boxel.realm-servers`) by asking each via
// `_realm-auth`, rather than reading the realm list directly out of
// `app.boxel.realms`. A transition fallback to the legacy key remains
// until the lazy migration that populates `app.boxel.realm-servers` has
// run on all active accounts.
module(
'Integration | matrix-service | boot assembly with trusted servers',
function (hooks) {
setupRenderingTest(hooks);
setupBaseRealm(hooks);
setupLocalIndexing(hooks);

// Don't autostart Matrix during realm setup: `setupIntegrationTestRealm`
// backfills the integration realm URL into `availableRealmIdentifiers`,
// which would mask a boot-assembly regression. We clear that backfill and
// run `start()` explicitly so the boot path alone populates the list.
let mockMatrixUtils = setupMockMatrix(hooks, {
loggedInAs: '@testuser:localhost',
activeRealms: [baseRealm.url, testRealmURL],
activeRealmServers: [testRealmServerURL],
});

hooks.beforeEach(async function (this: RenderingTestContext) {
await setupIntegrationTestRealm({
mockMatrixUtils,
contents: {},
startMatrix: false,
});
let realmServer = getService('realm-server') as RealmServerService;
await realmServer.setAvailableRealmIdentifiers([]);
let matrixService = getService('matrix-service') as MatrixService;
await matrixService.ready;
await matrixService.start();
});

test('boot populates availableRealmIdentifiers when `app.boxel.realm-servers` is set', async function (assert) {
let realmServer = getService('realm-server') as RealmServerService;
assert.ok(
realmServer.availableRealmIdentifiers.includes(ri(testRealmURL)),
'testRealmURL is present in availableRealmIdentifiers',
);
});

test('fetchUserRealmsFromTrustedServers returns realms advertised by `_realm-auth`', async function (assert) {
let realmServer = getService('realm-server') as RealmServerService;
let realms = await realmServer.fetchUserRealmsFromTrustedServers([
testRealmServerURL,
]);
assert.deepEqual(
realms,
[testRealmURL],
'returns the trusted server’s realms',
);
});

test('fetchUserRealmsFromTrustedServers returns [] for an empty input', async function (assert) {
let realmServer = getService('realm-server') as RealmServerService;
let realms = await realmServer.fetchUserRealmsFromTrustedServers([]);
assert.deepEqual(realms, [], 'short-circuits without any HTTP call');
});

test('fetchUserRealmsFromTrustedServers rejects non-own realm-server URLs', async function (assert) {
let realmServer = getService('realm-server') as RealmServerService;
await assert.rejects(
realmServer.fetchUserRealmsFromTrustedServers([
'https://other-server.example/',
]),
/Multi-realm server support is not yet implemented/,
);
});
},
);

module(
'Integration | matrix-service | trusted-servers result survives legacy event',
function (hooks) {
setupRenderingTest(hooks);
setupBaseRealm(hooks);
setupLocalIndexing(hooks);

// The mock matrix client's `startClient` re-emits a synthetic
// `app.boxel.realms` AccountData event carrying `activeRealms`. When
// trusted servers are authoritative, that re-emission must NOT
// overwrite the realms the trusted-servers boot path discovered.
// `activeRealms` is empty while `_realm-auth` advertises testRealmURL,
// so a clobber would zero the available-realms list and drop
// testRealmURL — making the regression observable as a missing realm.
let mockMatrixUtils = setupMockMatrix(hooks, {
loggedInAs: '@testuser:localhost',
activeRealms: [],
activeRealmServers: [testRealmServerURL],
});

hooks.beforeEach(async function (this: RenderingTestContext) {
await setupIntegrationTestRealm({
mockMatrixUtils,
contents: {},
startMatrix: false,
});
// Clear the URL `setupIntegrationTestRealm` backfills so the boot path —
// and the `startClient()` legacy-event re-emission it triggers — is
// solely responsible for the final list.
let realmServer = getService('realm-server') as RealmServerService;
await realmServer.setAvailableRealmIdentifiers([]);
let matrixService = getService('matrix-service') as MatrixService;
await matrixService.ready;
await matrixService.start();
});

test('legacy realms event does not overwrite the trusted-servers boot result', async function (assert) {
let realmServer = getService('realm-server') as RealmServerService;
assert.ok(
realmServer.availableRealmIdentifiers.includes(ri(testRealmURL)),
'testRealmURL from _realm-auth survives the legacy event',
);
});
},
);

module(
'Integration | matrix-service | boot assembly fallback to legacy realms',
function (hooks) {
setupRenderingTest(hooks);
setupBaseRealm(hooks);
setupLocalIndexing(hooks);

// No activeRealmServers — the mock returns `{ realmServers: [] }`, the
// same shape the host sees for a user who hasn't yet been migrated to
// `app.boxel.realm-servers`.
let mockMatrixUtils = setupMockMatrix(hooks, {
loggedInAs: '@testuser:localhost',
activeRealms: [baseRealm.url, testRealmURL],
});

hooks.beforeEach(async function (this: RenderingTestContext) {
await setupIntegrationTestRealm({
mockMatrixUtils,
contents: {},
startMatrix: false,
});
// Clear the backfilled URL so the legacy-fallback boot path is what
// populates the list, not `setupIntegrationTestRealm`.
let realmServer = getService('realm-server') as RealmServerService;
await realmServer.setAvailableRealmIdentifiers([]);
let matrixService = getService('matrix-service') as MatrixService;
await matrixService.ready;
await matrixService.start();
});

test('boot still populates realms from `app.boxel.realms`', async function (assert) {
let realmServer = getService('realm-server') as RealmServerService;
assert.ok(
realmServer.availableRealmIdentifiers.includes(ri(testRealmURL)),
'testRealmURL is present in availableRealmIdentifiers',
);
});
},
);
Loading