diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 4e66b9aad2..ad86cf41a9 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -55,6 +55,7 @@ import { APP_BOXEL_REALM_EVENT_TYPE, APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE, APP_BOXEL_REALMS_EVENT_TYPE, + APP_BOXEL_REALM_SERVERS_EVENT_TYPE, APP_BOXEL_WORKSPACE_FAVORITES_EVENT_TYPE, APP_BOXEL_ACTIVE_LLM, APP_BOXEL_LLM_MODE, @@ -174,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(); @@ -393,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; @@ -711,6 +760,44 @@ export default class MatrixService extends Service { await this.realmServer.setAvailableRealmIdentifiers(newRealms.map(ri)); } + public async getRealmServersFromAccountData(): Promise { + let { realmServers = [] } = + ((await this.client.getAccountDataFromServer( + APP_BOXEL_REALM_SERVERS_EVENT_TYPE, + )) as { realmServers: string[] }) ?? {}; + return realmServers; + } + + public async setRealmServersInAccountData( + realmServers: string[], + ): Promise { + await this.client.setAccountData(APP_BOXEL_REALM_SERVERS_EVENT_TYPE, { + realmServers, + }); + } + + public async appendRealmServerToAccountData( + realmServerURLString: string, + ): Promise { + let realmServers = await this.getRealmServersFromAccountData(); + if (realmServers.includes(realmServerURLString)) { + return; + } + await this.setRealmServersInAccountData([ + ...realmServers, + realmServerURLString, + ]); + } + + public async removeRealmServerFromAccountData( + realmServerURLString: string, + ): Promise { + let realmServers = await this.getRealmServersFromAccountData(); + await this.setRealmServersInAccountData( + realmServers.filter((s) => s !== realmServerURLString), + ); + } + public async getWorkspaceFavorites(): Promise { let { favorites = [] } = ((await this.client.getAccountDataFromServer( @@ -797,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, ); @@ -818,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'); @@ -829,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 4ca9d4d599..e1d818cc09 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/helpers/mock-matrix.ts b/packages/host/tests/helpers/mock-matrix.ts index 53cba982b2..afd79f5daf 100644 --- a/packages/host/tests/helpers/mock-matrix.ts +++ b/packages/host/tests/helpers/mock-matrix.ts @@ -24,6 +24,7 @@ export interface Config { loggedInAs?: string; displayName?: string; activeRealms?: string[]; + activeRealmServers?: string[]; realmPermissions?: Record; expiresInSec?: number; autostart?: boolean; diff --git a/packages/host/tests/helpers/mock-matrix/_client.ts b/packages/host/tests/helpers/mock-matrix/_client.ts index 920120d33e..165c45a801 100644 --- a/packages/host/tests/helpers/mock-matrix/_client.ts +++ b/packages/host/tests/helpers/mock-matrix/_client.ts @@ -17,6 +17,7 @@ import { APP_BOXEL_COMMAND_RESULT_EVENT_TYPE, APP_BOXEL_DEBUG_MESSAGE_EVENT_TYPE, APP_BOXEL_REALMS_EVENT_TYPE, + APP_BOXEL_REALM_SERVERS_EVENT_TYPE, APP_BOXEL_ROOM_SKILLS_EVENT_TYPE, APP_BOXEL_REALM_EVENT_TYPE, APP_BOXEL_CODE_PATCH_RESULT_EVENT_TYPE, @@ -98,6 +99,10 @@ export class MockClient implements ExtendedClient { return { realms: this.sdkOpts.activeRealms ?? [], } as unknown as K; + } else if (_eventType === APP_BOXEL_REALM_SERVERS_EVENT_TYPE) { + return { + realmServers: this.sdkOpts.activeRealmServers ?? [], + } as unknown as K; } else if (_eventType === APP_BOXEL_SYSTEM_CARD_EVENT_TYPE) { return (this.sdkOpts.systemCardAccountData ?? null) as unknown as K; } else if (_eventType === APP_BOXEL_WORKSPACE_FAVORITES_EVENT_TYPE) { @@ -230,6 +235,8 @@ export class MockClient implements ExtendedClient { ): Promise<{}> { if (type === APP_BOXEL_REALMS_EVENT_TYPE) { this.sdkOpts.activeRealms = (data as any).realms; + } else if (type === APP_BOXEL_REALM_SERVERS_EVENT_TYPE) { + this.sdkOpts.activeRealmServers = (data as any).realmServers; } else if (type === 'm.direct') { this.sdkOpts.directRooms = (data as any)[this.loggedInAs!]; } else if (type === APP_BOXEL_SYSTEM_CARD_EVENT_TYPE) { @@ -655,6 +662,7 @@ export class MockClient implements ExtendedClient { private eventHandlerType(type: string) { switch (type) { case APP_BOXEL_REALMS_EVENT_TYPE: + case APP_BOXEL_REALM_SERVERS_EVENT_TYPE: case APP_BOXEL_SYSTEM_CARD_EVENT_TYPE: case APP_BOXEL_WORKSPACE_FAVORITES_EVENT_TYPE: case 'm.direct': 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..40af38cd29 --- /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', + ); + }); + }, +); diff --git a/packages/host/tests/integration/matrix-service-realm-servers-test.ts b/packages/host/tests/integration/matrix-service-realm-servers-test.ts new file mode 100644 index 0000000000..4515d30655 --- /dev/null +++ b/packages/host/tests/integration/matrix-service-realm-servers-test.ts @@ -0,0 +1,109 @@ +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { baseRealm } from '@cardstack/runtime-common'; + +import type MatrixService from '@cardstack/host/services/matrix-service'; + +import { + testRealmURL, + setupIntegrationTestRealm, + setupLocalIndexing, +} from '../helpers'; + +import { setupBaseRealm } from '../helpers/base-realm'; + +import { setupMockMatrix } from '../helpers/mock-matrix'; + +import { setupRenderingTest } from '../helpers/setup'; + +// CS-11655: the matrix-service exposes read/write helpers for the new +// `app.boxel.realm-servers` account-data event. These tests round-trip +// the `{ realmServers }` payload through the mock matrix client and +// confirm append + remove behave idempotently. +module( + 'Integration | matrix-service | realm-servers account data', + 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 (this: RenderingTestContext) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + }); + }); + + test('get returns empty when no event has been written', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + let servers = await matrixService.getRealmServersFromAccountData(); + assert.deepEqual( + servers, + [], + 'returns an empty array when the event is absent', + ); + }); + + test('set then get round-trips the realmServers payload', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + let payload = ['https://server-a.example/', 'https://server-b.example/']; + + await matrixService.setRealmServersInAccountData(payload); + + let read = await matrixService.getRealmServersFromAccountData(); + assert.deepEqual(read, payload, 'reads back exactly what was written'); + }); + + test('append is idempotent and preserves prior entries', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + let a = 'https://server-a.example/'; + let b = 'https://server-b.example/'; + + await matrixService.appendRealmServerToAccountData(a); + await matrixService.appendRealmServerToAccountData(b); + // Re-appending an existing server is a no-op. + await matrixService.appendRealmServerToAccountData(a); + + assert.deepEqual( + await matrixService.getRealmServersFromAccountData(), + [a, b], + 'append preserves order and does not duplicate', + ); + }); + + test('remove drops the entry and leaves others intact', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + let a = 'https://server-a.example/'; + let b = 'https://server-b.example/'; + + await matrixService.setRealmServersInAccountData([a, b]); + await matrixService.removeRealmServerFromAccountData(a); + + assert.deepEqual( + await matrixService.getRealmServersFromAccountData(), + [b], + 'only the targeted server is removed', + ); + + // Removing something not in the list is a no-op. + await matrixService.removeRealmServerFromAccountData( + 'https://not-present.example/', + ); + assert.deepEqual( + await matrixService.getRealmServersFromAccountData(), + [b], + 'removing a non-existent server leaves the list unchanged', + ); + }); + }, +); diff --git a/packages/matrix/support/matrix-constants.ts b/packages/matrix/support/matrix-constants.ts index c7b0d0bf2b..81ba97fc6c 100644 --- a/packages/matrix/support/matrix-constants.ts +++ b/packages/matrix/support/matrix-constants.ts @@ -11,6 +11,7 @@ export const APP_BOXEL_COMMAND_RESULT_WITH_NO_OUTPUT_MSGTYPE = export const APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE = 'app.boxel.realm-server-event'; export const APP_BOXEL_REALMS_EVENT_TYPE = 'app.boxel.realms'; +export const APP_BOXEL_REALM_SERVERS_EVENT_TYPE = 'app.boxel.realm-servers'; export const APP_BOXEL_SYSTEM_CARD_EVENT_TYPE = 'app.boxel.system-card'; export const APP_BOXEL_CODE_PATCH_CORRECTNESS_MSGTYPE = 'app.boxel.codePatchCorrectness'; diff --git a/packages/realm-server/handlers/handle-upsert-realm-user-permission.ts b/packages/realm-server/handlers/handle-upsert-realm-user-permission.ts index 21541dbbcb..8db5ae2f85 100644 --- a/packages/realm-server/handlers/handle-upsert-realm-user-permission.ts +++ b/packages/realm-server/handlers/handle-upsert-realm-user-permission.ts @@ -13,6 +13,7 @@ import { import type { CreateRoutesArgs } from '../routes.ts'; import { adminImpersonateUser, + appendRealmServerToUserAccountData, appendRealmToUserAccountData, loginAsMatrixAdmin, logoutMatrixAccessToken, @@ -192,6 +193,19 @@ export default function handleUpsertRealmUserPermission({ realmURL: normalizedRealmHref, }); appendedToAccountData = !alreadyPresent; + // Keep `app.boxel.realm-servers` in lockstep with `app.boxel.realms` + // during the source-of-truth transition (CS-11655). Derive the + // realm-server origin from the realm URL — the host normalises the + // same way via the JWT's `realmServerURL` claim, but JWTs aren't + // in scope on this admin-impersonate path. + await appendRealmServerToUserAccountData({ + matrixURL: matrixClient.matrixURL, + userId: user, + userAccessToken: userToken, + realmServerURL: ensureTrailingSlash( + new URL(normalizedRealmHref).origin, + ), + }); } catch (e: any) { matrixAccountDataWarning = `account_data sync failed: ${e?.message ?? String(e)}`; log.warn( diff --git a/packages/realm-server/synapse.ts b/packages/realm-server/synapse.ts index 23f1fd766b..1716e22200 100644 --- a/packages/realm-server/synapse.ts +++ b/packages/realm-server/synapse.ts @@ -5,7 +5,10 @@ import { resolve, join } from 'path'; import { createHmac } from 'crypto'; import yaml from 'yaml'; import { existsSync } from 'fs'; -import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common'; +import { + APP_BOXEL_REALMS_EVENT_TYPE, + APP_BOXEL_REALM_SERVERS_EVENT_TYPE, +} from '@cardstack/runtime-common'; function homeserverFile(): string { if (process.env.BOXEL_ENVIRONMENT) { @@ -226,50 +229,110 @@ export async function appendRealmToUserAccountData({ userId: string; userAccessToken: string; realmURL: string; +}): Promise<{ alreadyPresent: boolean }> { + return appendStringToUserAccountDataArray({ + matrixURL, + userId, + userAccessToken, + eventType: APP_BOXEL_REALMS_EVENT_TYPE, + arrayKey: 'realms', + value: realmURL, + }); +} + +// Append a single realm-server URL to a user's `app.boxel.realm-servers` +// account_data, preserving any existing entries. Same semantics as +// appendRealmToUserAccountData (idempotent, retry-on-stomp). +export async function appendRealmServerToUserAccountData({ + matrixURL, + userId, + userAccessToken, + realmServerURL, +}: { + matrixURL: URL; + userId: string; + userAccessToken: string; + realmServerURL: string; +}): Promise<{ alreadyPresent: boolean }> { + return appendStringToUserAccountDataArray({ + matrixURL, + userId, + userAccessToken, + eventType: APP_BOXEL_REALM_SERVERS_EVENT_TYPE, + arrayKey: 'realmServers', + value: realmServerURL, + }); +} + +async function appendStringToUserAccountDataArray({ + matrixURL, + userId, + userAccessToken, + eventType, + arrayKey, + value, +}: { + matrixURL: URL; + userId: string; + userAccessToken: string; + eventType: string; + arrayKey: string; + value: string; }): Promise<{ alreadyPresent: boolean }> { let firstAttemptAlreadyPresent: boolean | undefined; for (let attempt = 1; attempt <= APPEND_REALM_MAX_ATTEMPTS; attempt++) { - let existing = await fetchRealmsAccountData( + let existing = await fetchAccountData( matrixURL, userId, userAccessToken, + eventType, ); - let realms = Array.isArray(existing.realms) ? existing.realms : []; - if (realms.includes(realmURL)) { + let entries = Array.isArray(existing[arrayKey]) + ? (existing[arrayKey] as unknown[]).filter( + (v): v is string => typeof v === 'string', + ) + : []; + if (entries.includes(value)) { // First-attempt observation: the caller cares whether THIS - // invocation appended, so a realm that was already there before + // invocation appended, so a value that was already there before // we did anything reads as `alreadyPresent: true`. A retry-loop - // observation: a concurrent writer added our realm for us — still + // observation: a concurrent writer added our value for us — still // a fresh append from the caller's perspective. return { alreadyPresent: firstAttemptAlreadyPresent ?? true }; } firstAttemptAlreadyPresent = false; - await putRealmsAccountData(matrixURL, userId, userAccessToken, { + await putAccountData(matrixURL, userId, userAccessToken, eventType, { ...existing, - realms: [...realms, realmURL], + [arrayKey]: [...entries, value], }); // Verify our entry survived; if another writer raced us we retry. - let verified = await fetchRealmsAccountData( + let verified = await fetchAccountData( matrixURL, userId, userAccessToken, + eventType, ); - let verifiedRealms = Array.isArray(verified.realms) ? verified.realms : []; - if (verifiedRealms.includes(realmURL)) { + let verifiedEntries = Array.isArray(verified[arrayKey]) + ? (verified[arrayKey] as unknown[]).filter( + (v): v is string => typeof v === 'string', + ) + : []; + if (verifiedEntries.includes(value)) { return { alreadyPresent: false }; } } throw new Error( - `matrix ${APP_BOXEL_REALMS_EVENT_TYPE} append for "${userId}" lost to a concurrent writer after ${APPEND_REALM_MAX_ATTEMPTS} attempts`, + `matrix ${eventType} append for "${userId}" lost to a concurrent writer after ${APPEND_REALM_MAX_ATTEMPTS} attempts`, ); } -async function fetchRealmsAccountData( +async function fetchAccountData( matrixURL: URL, userId: string, userAccessToken: string, + eventType: string, ): Promise> { - let path = `_matrix/client/v3/user/${encodeURIComponent(userId)}/account_data/${APP_BOXEL_REALMS_EVENT_TYPE}`; + let path = `_matrix/client/v3/user/${encodeURIComponent(userId)}/account_data/${eventType}`; let response = await fetch(`${matrixURL.href}${path}`, { headers: { Authorization: `Bearer ${userAccessToken}` }, }); @@ -278,19 +341,20 @@ async function fetchRealmsAccountData( } if (!response.ok) { throw new Error( - `matrix GET ${APP_BOXEL_REALMS_EVENT_TYPE} for "${userId}" failed: HTTP ${response.status} ${await response.text()}`, + `matrix GET ${eventType} for "${userId}" failed: HTTP ${response.status} ${await response.text()}`, ); } return (await response.json()) as Record; } -async function putRealmsAccountData( +async function putAccountData( matrixURL: URL, userId: string, userAccessToken: string, + eventType: string, content: Record, ): Promise { - let path = `_matrix/client/v3/user/${encodeURIComponent(userId)}/account_data/${APP_BOXEL_REALMS_EVENT_TYPE}`; + let path = `_matrix/client/v3/user/${encodeURIComponent(userId)}/account_data/${eventType}`; let response = await fetch(`${matrixURL.href}${path}`, { method: 'PUT', headers: { @@ -301,7 +365,7 @@ async function putRealmsAccountData( }); if (!response.ok) { throw new Error( - `matrix PUT ${APP_BOXEL_REALMS_EVENT_TYPE} for "${userId}" failed: HTTP ${response.status} ${await response.text()}`, + `matrix PUT ${eventType} for "${userId}" failed: HTTP ${response.status} ${await response.text()}`, ); } } diff --git a/packages/realm-server/tests/server-endpoints/maintenance-endpoints-test.ts b/packages/realm-server/tests/server-endpoints/maintenance-endpoints-test.ts index bce26054ea..50b8d69bba 100644 --- a/packages/realm-server/tests/server-endpoints/maintenance-endpoints-test.ts +++ b/packages/realm-server/tests/server-endpoints/maintenance-endpoints-test.ts @@ -6,7 +6,10 @@ import sinon from 'sinon'; import { PgAdapter, PgQueueRunner } from '@cardstack/postgres'; import { sumUpCreditsLedger } from '@cardstack/billing/billing-queries'; import { boxelUIChecker } from '../../lib/boxel-ui-change-checker.ts'; -import { fetchRealmPermissions } from '@cardstack/runtime-common'; +import { + ensureTrailingSlash, + fetchRealmPermissions, +} from '@cardstack/runtime-common'; import { grafanaSecret, insertUser, @@ -17,11 +20,15 @@ import { import { createJWT as createRealmServerJWT } from '../../utils/jwt.ts'; import { adminImpersonateUser, + appendRealmServerToUserAccountData, appendRealmToUserAccountData, loginAsMatrixAdmin, registerUser, } from '../../synapse.ts'; -import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common'; +import { + APP_BOXEL_REALMS_EVENT_TYPE, + APP_BOXEL_REALM_SERVERS_EVENT_TYPE, +} from '@cardstack/runtime-common'; import { setupServerEndpointsTest, testRealmURL } from './helpers.ts'; import '@cardstack/runtime-common/helpers/code-equality-assertion'; @@ -1714,6 +1721,145 @@ module(`server-endpoints/${basename(import.meta.filename)}`, function () { 'new realm appended after prior entry', ); }); + + test('appendRealmServerToUserAccountData round-trips and preserves prior entries', async function (assert) { + // CS-11655: parallel write of the trusted-realm-servers list. + // Pre-seed an unrelated server origin, then ensure a new server is + // appended without dropping or reordering existing entries. + let localpart = `grafana-rs-preserve-${uuidv4().slice(0, 8)}`; + let userId = `@${localpart}:localhost`; + await registerUser({ + matrixURL, + displayname: localpart, + username: localpart, + password: 'password', + registrationSecret: matrixRegistrationSecret, + }); + let priorServer = 'http://other-realm-server.example/'; + let newServer = ensureTrailingSlash(new URL(testRealmURL.href).origin); + + let adminToken = await loginAsMatrixAdmin({ + matrixURL, + adminUsername: 'admin', + adminPassword: 'password', + }); + let userToken = await adminImpersonateUser({ + matrixURL, + adminAccessToken: adminToken, + userId, + }); + let seed = await fetch( + `${matrixURL.href}_matrix/client/v3/user/${encodeURIComponent( + userId, + )}/account_data/${APP_BOXEL_REALM_SERVERS_EVENT_TYPE}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${userToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ realmServers: [priorServer] }), + }, + ); + assert.strictEqual(seed.status, 200, 'seed PUT succeeded'); + + let result = await appendRealmServerToUserAccountData({ + matrixURL, + userId, + userAccessToken: userToken, + realmServerURL: newServer, + }); + assert.false( + result.alreadyPresent, + 'realm server was not already present', + ); + + let after = await fetch( + `${matrixURL.href}_matrix/client/v3/user/${encodeURIComponent( + userId, + )}/account_data/${APP_BOXEL_REALM_SERVERS_EVENT_TYPE}`, + { headers: { Authorization: `Bearer ${userToken}` } }, + ); + assert.strictEqual(after.status, 200, 'account_data GET returned 200'); + let body = (await after.json()) as { realmServers?: string[] }; + assert.deepEqual( + body.realmServers, + [priorServer, newServer], + 'new realm server appended after prior entry', + ); + + // Idempotent: re-appending the same server is a no-op. + let second = await appendRealmServerToUserAccountData({ + matrixURL, + userId, + userAccessToken: userToken, + realmServerURL: newServer, + }); + assert.true( + second.alreadyPresent, + 'second append signals alreadyPresent', + ); + }); + + test("grafana upsert syncs realm-server origin to granted user's app.boxel.realm-servers", async function (assert) { + // CS-11655: a grafana grant must populate both keys during the + // source-of-truth transition. realm-servers is the new key boot + // assembly (CS-11658) will read from. + let localpart = `grafana-grant-rs-${uuidv4().slice(0, 8)}`; + let userId = `@${localpart}:localhost`; + await registerUser({ + matrixURL, + displayname: localpart, + username: localpart, + password: 'password', + registrationSecret: matrixRegistrationSecret, + }); + + let response = await context.request + .post( + `/_grafana-upsert-realm-user-permission` + + `?realm=${encodeURIComponent(testRealmURL.href)}` + + `&user=${encodeURIComponent(userId)}` + + `&read=true&write=false`, + ) + .set('Authorization', `Bearer ${grafanaSecret}`) + .set('Content-Type', 'application/json'); + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.notOk( + response.body.matrixAccountDataWarning, + `no matrix warning: ${response.body.matrixAccountDataWarning}`, + ); + + let adminToken = await loginAsMatrixAdmin({ + matrixURL, + adminUsername: 'admin', + adminPassword: 'password', + }); + let userToken = await adminImpersonateUser({ + matrixURL, + adminAccessToken: adminToken, + userId, + }); + let accountDataResponse = await fetch( + `${matrixURL.href}_matrix/client/v3/user/${encodeURIComponent( + userId, + )}/account_data/${APP_BOXEL_REALM_SERVERS_EVENT_TYPE}`, + { headers: { Authorization: `Bearer ${userToken}` } }, + ); + assert.strictEqual( + accountDataResponse.status, + 200, + 'realm-servers account_data row exists', + ); + let body = (await accountDataResponse.json()) as { + realmServers?: string[]; + }; + assert.deepEqual( + body.realmServers, + [ensureTrailingSlash(new URL(testRealmURL.href).origin)], + 'realm-server origin appears in the user account_data', + ); + }); }, ); }); diff --git a/packages/runtime-common/matrix-constants.ts b/packages/runtime-common/matrix-constants.ts index 3bfd3760d5..50aae0a801 100644 --- a/packages/runtime-common/matrix-constants.ts +++ b/packages/runtime-common/matrix-constants.ts @@ -22,6 +22,11 @@ export const APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE = 'app.boxel.realm-server-event'; export const APP_BOXEL_ROOM_SKILLS_EVENT_TYPE = 'app.boxel.room.skills'; export const APP_BOXEL_REALMS_EVENT_TYPE = 'app.boxel.realms'; +export const APP_BOXEL_REALM_SERVERS_EVENT_TYPE = 'app.boxel.realm-servers'; + +export interface AppBoxelRealmServersContent { + realmServers: string[]; +} export const APP_BOXEL_WORKSPACE_FAVORITES_EVENT_TYPE = 'app.boxel.workspace-favorites'; export const APP_BOXEL_SYSTEM_CARD_EVENT_TYPE = 'app.boxel.system-card';