From fd642867d9512c83d1948b2c51ca85b53dd1c49e Mon Sep 17 00:00:00 2001 From: ylm Date: Thu, 18 Jun 2026 13:41:52 -0400 Subject: [PATCH] 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 1a59b35410..0ce074eea2 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 71d39a7118..567e09112d 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 8e26f56d46..1c18acac00 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) { @@ -632,6 +639,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 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 aef86026e4..f1e5aa07d0 100644 --- a/packages/realm-server/synapse.ts +++ b/packages/realm-server/synapse.ts @@ -4,7 +4,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) { @@ -219,50 +222,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}` }, }); @@ -271,19 +334,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: { @@ -294,7 +358,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 70e2037ecd..efd90e9203 100644 --- a/packages/realm-server/tests/server-endpoints/maintenance-endpoints-test.ts +++ b/packages/realm-server/tests/server-endpoints/maintenance-endpoints-test.ts @@ -5,7 +5,10 @@ import sinon from 'sinon'; import { PgAdapter, PgQueueRunner } from '@cardstack/postgres'; import { sumUpCreditsLedger } from '@cardstack/billing/billing-queries'; import * as boxelUIChangeChecker from '../../lib/boxel-ui-change-checker.ts'; -import { fetchRealmPermissions } from '@cardstack/runtime-common'; +import { + ensureTrailingSlash, + fetchRealmPermissions, +} from '@cardstack/runtime-common'; import { grafanaSecret, insertUser, @@ -16,11 +19,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'; @@ -1713,6 +1720,145 @@ module(`server-endpoints/${basename(__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';