diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 0ce074eea2..1f6f9e6eb5 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; + // CS-11658: 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 based on + // the new key's presence; 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,43 @@ 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[]; + // CS-11658: 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) { + let realmURLs = + await this.realmServer.fetchUserRealmsFromTrustedServers( + realmServers, + ); + await this.realmServer.setAvailableRealmIdentifiers( + realmURLs.map(ri), + ); + if (this.postLoginCompleted) { + await this.loginToRealms(); + await this.loadMoreAuthRooms(realmURLs); + } } break; + } case APP_BOXEL_SYSTEM_CARD_EVENT_TYPE: await this.setSystemCard(e.event.content.id); break; @@ -836,17 +870,46 @@ 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 ?? []; + // CS-11658: 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 — necessary until CS-11659's lazy migration populates + // the new key for existing users. Remove the fallback once that + // migration 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 +920,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 +929,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 de0ac37780..a1ab3714fb 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(); } + // CS-11658: 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. v1 keeps + // the single-server invariant — assertOwnRealmServer() rejects any list + // that includes a non-own server 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 0000000000..f06f1e2540 --- /dev/null +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -0,0 +1,168 @@ +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 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); + +// CS-11658: boot assembles the available-realms list from the user's +// trusted realm-servers (`app.boxel.realm-servers`) by asking each via +// `_realm-auth`, instead of reading the realm list directly out of +// `app.boxel.realms`. A transition fallback to the legacy key remains +// until CS-11659's lazy migration has run on all active accounts. +module( + 'Integration | matrix-service | boot assembly with trusted servers', + function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + activeRealmServers: [testRealmServerURL], + autostart: true, + }); + + hooks.beforeEach(async function (this: RenderingTestContext) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }); + }); + + 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 with `activeRealms` content. + // With the new key authoritative, that re-emission must NOT overwrite + // the realms the trusted-servers boot path discovered. The setup + // below deliberately diverges activeRealms from realmPermissions so + // the bug (if reintroduced) shows up as a missing realm from the + // _realm-auth response. + const otherRealmURL = 'http://test-realm/test-other/'; + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [], // synthetic legacy event would clear availableRealms + activeRealmServers: [testRealmServerURL], + realmPermissions: { + [testRealmURL]: ['read', 'write'], + [otherRealmURL]: ['read', 'write'], + }, + autostart: true, + }); + + hooks.beforeEach(async function (this: RenderingTestContext) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }); + }); + + 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', + ); + assert.ok( + realmServer.availableRealmIdentifiers.includes(ri(otherRealmURL)), + 'otherRealmURL from _realm-auth survives the legacy event (regression guard)', + ); + }); + }, +); + +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 been migrated to + // `app.boxel.realm-servers` yet (CS-11659). + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + autostart: true, + }); + + hooks.beforeEach(async function (this: RenderingTestContext) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }); + }); + + 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', + ); + }); + }, +);