diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 0ce074eea28..f07c51e2aed 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -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 = new TrackedMap(); @@ -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; @@ -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, ); @@ -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'); @@ -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()) diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index de0ac377801..02d95b75ff0 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -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 { + 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; + return Object.keys(tokens); + }), + ); + return [...new Set(perServerRealmURLs.flat())]; + } + @cached get availableRealmIdentifiers(): RealmIdentifier[] { return this.availableRealms.map((r) => ri(r.url)); diff --git a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts new file mode 100644 index 00000000000..40af38cd297 --- /dev/null +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -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', + ); + }); + }, +);