From 5fc68b9524e18337170696a955aeedc571945d33 Mon Sep 17 00:00:00 2001 From: ylm Date: Thu, 18 Jun 2026 13:41:52 -0400 Subject: [PATCH 1/7] CS-11655: add app.boxel.realm-servers event type + read/write helpers Foundational data-model change for the source-of-truth rework. A user's matrix account data should store the set of *trusted realm servers* alongside the existing flat realm list during the transition. Boot assembly and lazy migration land in follow-ups (CS-11658, CS-11659). - runtime-common: new APP_BOXEL_REALM_SERVERS_EVENT_TYPE constant and AppBoxelRealmServersContent payload type. - host matrix-service: get/set/append/remove helpers for the new key, mirroring the existing realms helpers. Legacy realms behavior is unchanged. - realm-server synapse: parallel appendRealmServerToUserAccountData with the same idempotent retry-on-stomp semantics (helpers factored to share the get/put plumbing). - realm-server upsert-permission handler: after the realm append, also writes the realm-server origin to app.boxel.realm-servers so both keys stay in lockstep during the transition. - mock matrix client: handles get/set + AccountData event routing for the new key, with a new activeRealmServers config option. - tests: host integration test round-trips the {realmServers} payload through the helpers (append idempotency, remove); realm-server endpoint tests cover direct helper behaviour and the grafana upsert sync path. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/host/app/services/matrix-service.ts | 39 +++++ packages/host/tests/helpers/mock-matrix.ts | 1 + .../host/tests/helpers/mock-matrix/_client.ts | 8 + .../matrix-service-realm-servers-test.ts | 109 +++++++++++++ packages/matrix/support/matrix-constants.ts | 1 + .../handle-upsert-realm-user-permission.ts | 14 ++ packages/realm-server/synapse.ts | 100 +++++++++--- .../maintenance-endpoints-test.ts | 150 +++++++++++++++++- packages/runtime-common/matrix-constants.ts | 5 + 9 files changed, 407 insertions(+), 20 deletions(-) create mode 100644 packages/host/tests/integration/matrix-service-realm-servers-test.ts diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 4e66b9aad28..f32aae01c09 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, @@ -711,6 +712,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( diff --git a/packages/host/tests/helpers/mock-matrix.ts b/packages/host/tests/helpers/mock-matrix.ts index 53cba982b27..afd79f5dafc 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 920120d33e9..165c45a801b 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-realm-servers-test.ts b/packages/host/tests/integration/matrix-service-realm-servers-test.ts new file mode 100644 index 00000000000..4515d306550 --- /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 c7b0d0bf2be..81ba97fc6c2 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 21541dbbcb0..8db5ae2f85c 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 23f1fd766b4..1716e222008 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 bce26054ea7..50b8d69bba6 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 3bfd3760d5f..50aae0a8011 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'; From 3af41196493f5778c225b7a309e687a5a647471e Mon Sep 17 00:00:00 2001 From: ylm Date: Thu, 18 Jun 2026 17:14:24 -0400 Subject: [PATCH 2/7] CS-11658: boot assembly from trusted servers via _realm-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "account data is the realm list" boot path with "account data is the list of trusted servers; ask each server which realms the user has." Builds on CS-11655 which introduced the new `app.boxel.realm-servers` account-data event type. - realm-server: new fetchUserRealmsFromTrustedServers() iterates the trusted-server URLs, POSTs _realm-auth on each, and returns the union of realm URLs. Preserves the single-server invariant by calling assertOwnRealmServer() — multi-realm-server federation is out of scope for v1. - matrix-service start(): reads APP_BOXEL_REALM_SERVERS_EVENT_TYPE in parallel with favorites and assembles user realms via the new helper. Hands the result to setAvailableRealmIdentifiers and initSlidingSync (replacing the direct read of app.boxel.realms). fetchCatalogRealms() is unchanged. - Transition fallback: when app.boxel.realm-servers is absent or empty, the boot falls back to reading the legacy app.boxel.realms key so existing users aren't broken before CS-11659's lazy migration has run on their account. The fallback is clearly marked for removal once that migration ships. - Tests: boot populates the realm list from the trusted-servers path; direct unit coverage of the new method (round-trip, empty input short-circuit, non-own server rejection); fallback module verifies the legacy path still works when realm-servers is empty. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/host/app/services/matrix-service.ts | 38 ++++-- packages/host/app/services/realm-server.ts | 43 +++++++ .../matrix-service-boot-assembly-test.ts | 120 ++++++++++++++++++ 3 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 packages/host/tests/integration/matrix-service-boot-assembly-test.ts diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index f32aae01c09..0285d946463 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -836,17 +836,41 @@ 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 ?? []; + 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 +881,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 +890,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 4ca9d4d599b..a6368bfc40d 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -259,6 +259,49 @@ 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 realmURLs = new Set(); + for (let serverURL of trustedServerURLs) { + 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; + for (let realmURL of Object.keys(tokens)) { + realmURLs.add(realmURL); + } + } + return [...realmURLs]; + } + @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..9e7f129dba6 --- /dev/null +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -0,0 +1,120 @@ +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 | 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', + ); + }); + }, +); From 5e66ead7800d00fd6ae58588ce8b476260380cbe Mon Sep 17 00:00:00 2001 From: ylm Date: Thu, 18 Jun 2026 17:22:49 -0400 Subject: [PATCH 3/7] Parallelize _realm-auth fetches across trusted servers Issue per-server _realm-auth requests concurrently via Promise.all, then union the returned realm URLs. Failure semantics unchanged: the first rejection still surfaces (graceful degradation is CS-11667). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/host/app/services/realm-server.ts | 47 +++++++++++----------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index a6368bfc40d..6dd14849aff 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -274,32 +274,31 @@ export default class RealmServerService extends Service { // TODO: remove once multi-realm-server federation lands. this.assertOwnRealmServer(trustedServerURLs); await this.login(); - let realmURLs = new Set(); - for (let serverURL of trustedServerURLs) { - 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}`, + 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; - for (let realmURL of Object.keys(tokens)) { - realmURLs.add(realmURL); - } - } - return [...realmURLs]; + 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 From a5905a8d23aa041bc0d12f4af71e49223ed5db86 Mon Sep 17 00:00:00 2001 From: ylm Date: Thu, 18 Jun 2026 17:43:33 -0400 Subject: [PATCH 4/7] Make trusted-servers authoritative against the legacy realms event Addresses the Codex review on PR #5285. The matrix sync triggered by `startClient()` re-emits the existing `app.boxel.realms` AccountData event, and the listener bound during `bindEventListeners` was overwriting the trusted-servers boot result with the legacy key's content. - matrix-service: track `trustedRealmServersAuthoritative`. Boot sets it true when `app.boxel.realm-servers` has entries; the new realm-servers listener flips it on at runtime if the key gains content. - Legacy `app.boxel.realms` listener: skip `setAvailableRealmIdentifiers` while the flag is true. Login side effects (loginToRealms, loadMoreAuthRooms) still run so authentication for new realms isn't dropped. - New `app.boxel.realm-servers` listener: re-fetch via _realm-auth, call setAvailableRealmIdentifiers, then loginToRealms / loadMoreAuthRooms post-login. Natural runtime counterpart to the new boot path. - Regression test: a setup where mock activeRealms = [] but realmPermissions advertises two realms via _realm-auth verifies both _realm-auth realms survive `startClient`'s synthetic event. Without the listener gating the test fails. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/host/app/services/matrix-service.ts | 49 +++++++++++++++++-- .../matrix-service-boot-assembly-test.ts | 48 ++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 0285d946463..7b61f716131 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; @@ -854,6 +888,11 @@ export default class MatrixService extends Service { // 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()) diff --git a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts index 9e7f129dba6..f06f1e2540d 100644 --- a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -86,6 +86,54 @@ module( }, ); +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) { From 20444913ec7676feeec65b7be19efb812485cf31 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 22 Jun 2026 14:44:49 -0400 Subject: [PATCH 5/7] Stop reactive realm-servers assembly from throwing into the void The app.boxel.realm-servers AccountData handler is async, so when fetchUserRealmsFromTrustedServers rejects (e.g. a list that isn't the user's own realm server), the rejection escapes as an unhandled rejection and takes down the app. Catch it: the authoritative, fail-loud assembly is the start()-time path; the reactive handler logs and leaves the available-realms list intact. Also simplify the trusted-servers-survives-legacy-event test to a single served realm. The second realm was advertised only to the matrix client's getJWT, not the realm-server mock's _realm-auth, so it never came back from boot assembly; the single-realm case fully proves the empty legacy event doesn't clobber the trusted-servers result. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/services/matrix-service.ts | 32 +++++++++++++------ .../matrix-service-boot-assembly-test.ts | 24 ++++---------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 7b61f716131..bc0ef4c17ee 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -424,16 +424,30 @@ export default class MatrixService extends Service { let realmServers = e.event.content.realmServers as string[]; this.trustedRealmServersAuthoritative = realmServers.length > 0; if (this.trustedRealmServersAuthoritative) { - let realmURLs = - await this.realmServer.fetchUserRealmsFromTrustedServers( - realmServers, + // 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, ); - await this.realmServer.setAvailableRealmIdentifiers( - realmURLs.map(ri), - ); - if (this.postLoginCompleted) { - await this.loginToRealms(); - await this.loadMoreAuthRooms(realmURLs); } } break; diff --git a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts index f06f1e2540d..b5dd50ffe57 100644 --- a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -94,22 +94,16 @@ module( 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/'; - + // `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: [], // synthetic legacy event would clear availableRealms + activeRealms: [], activeRealmServers: [testRealmServerURL], - realmPermissions: { - [testRealmURL]: ['read', 'write'], - [otherRealmURL]: ['read', 'write'], - }, autostart: true, }); @@ -126,10 +120,6 @@ module( 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)', - ); }); }, ); From 4b090bb010e1446783b0abb982e59d2eb1d2275f Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 22 Jun 2026 16:23:43 -0400 Subject: [PATCH 6/7] Make boot-assembly tests fail on regression The three boot-assembly assertions were masked by setupIntegrationTestRealm(), which backfills the integration realm URL into availableRealmIdentifiers, so includes(ri(testRealmURL)) passed even if the boot path produced the wrong list. Stop autostarting Matrix during realm setup, clear the backfilled identifier, then run start() explicitly so the boot path alone is responsible for the populated list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../matrix-service-boot-assembly-test.ts | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts index b5dd50ffe57..769120e0f7f 100644 --- a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -6,6 +6,7 @@ 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 { @@ -34,18 +35,27 @@ module( 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], - autostart: true, }); 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) { @@ -104,14 +114,22 @@ module( loggedInAs: '@testuser:localhost', activeRealms: [], activeRealmServers: [testRealmServerURL], - autostart: true, }); 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) { @@ -137,14 +155,21 @@ module( let mockMatrixUtils = setupMockMatrix(hooks, { loggedInAs: '@testuser:localhost', activeRealms: [baseRealm.url, testRealmURL], - autostart: true, }); 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) { From 28e0b6ad3dae5a47aa79bbbbf731d21f667e8c12 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 23 Jun 2026 10:02:09 -0400 Subject: [PATCH 7/7] Make boot-assembly comments evergreen Strip ticket IDs, version labels, and temporal phrasing from the comments introduced for trusted-server boot assembly so they describe the current contract rather than the delivery state. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/services/matrix-service.ts | 25 ++++++++++--------- packages/host/app/services/realm-server.ts | 10 ++++---- .../matrix-service-boot-assembly-test.ts | 13 +++++----- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index bc0ef4c17ee..ad86cf41a9c 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -175,12 +175,12 @@ 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. + // 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 = @@ -403,8 +403,8 @@ export default class MatrixService extends Service { switch (e.event.type) { 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 + // 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. @@ -895,12 +895,13 @@ export default class MatrixService extends Service { ]); this.workspaceFavorites = favoritesData?.favorites ?? []; - // CS-11658: boot assembles the realm list from trusted servers via + // 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. + // 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 diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index 6dd14849aff..e1d818cc092 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -259,11 +259,11 @@ 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 + // 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[], diff --git a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts index 769120e0f7f..40af38cd297 100644 --- a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -23,11 +23,12 @@ 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 +// 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 CS-11659's lazy migration has run on all active accounts. +// 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) { @@ -150,8 +151,8 @@ module( 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). + // 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],