From 87408273e80201fabbf23ac6ce0563baf20d0971 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 21 Oct 2026 20:22:14 -0400 Subject: [PATCH 1/6] Send a world-readable realm's events to one shared room A world-readable realm sent each realm event to the session room of every user with a session room, so a write to a public realm cost Synapse a persisted event per account. It now sends the event once, into a room of its own that hosts join when they subscribe to the realm. - realm_event_rooms table maps a world-readable realm to its room - Realm#ensureRealmEventRoom creates the room on first request: anyone may join, only the realm's matrix user may send or change state - POST /_realm-event-rooms answers a signed-in host with the rooms of the world-readable realms it names - broadcastRealmEvent sends to that room; fetchRealmSessionRooms returns only users with their own grant - the host joins the room on its first subscription to a realm, and sliding sync carries these rooms in a list of their own, out of the AI room list Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SGrCsG5woc6fYqHu7BBZdY --- packages/host/app/services/matrix-service.ts | 18 ++ packages/host/app/services/message-service.ts | 4 + packages/host/app/services/realm-server.ts | 77 +++++++++ ...31_schema.sql => 1792619085178_schema.sql} | 0 packages/host/tests/helpers/index.gts | 1 + .../tests/helpers/realm-server-mock/index.ts | 9 + .../tests/helpers/realm-server-mock/routes.ts | 18 ++ .../tests/helpers/realm-server-mock/types.ts | 4 + .../tests/integration/message-service-test.ts | 44 ++++- .../1792619085178_create-realm-event-rooms.js | 21 +++ packages/postgres/scripts/schema-dump.sh | 1 + .../handlers/handle-realm-event-rooms.ts | 98 +++++++++++ packages/realm-server/node-realm.ts | 35 ++++ packages/realm-server/routes.ts | 6 + .../scripts/load-harness/lib/realm-events.ts | 61 ++++++- .../scripts/load-harness/run-load.ts | 19 ++- packages/realm-server/tests/helpers/index.ts | 46 +++++- .../realm-server/tests/node-realm-test.ts | 85 ++++++++++ .../tests/realm-event-rooms-test.ts | 155 ++++++++++++++++++ .../tests/session-room-queries-test.ts | 148 ++++++++--------- .../db-queries/session-room-queries.ts | 88 +++++++--- packages/runtime-common/matrix-client.ts | 47 ++++++ packages/runtime-common/matrix-constants.ts | 15 +- packages/runtime-common/realm.ts | 38 +++++ 24 files changed, 921 insertions(+), 117 deletions(-) rename packages/host/config/schema/{1792465777331_schema.sql => 1792619085178_schema.sql} (100%) create mode 100644 packages/postgres/migrations/1792619085178_create-realm-event-rooms.js create mode 100644 packages/realm-server/handlers/handle-realm-event-rooms.ts create mode 100644 packages/realm-server/tests/realm-event-rooms-test.ts diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 81794c60223..ee6ad2ab8a9 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -59,6 +59,7 @@ import { APP_BOXEL_REALM_EVENT_TYPE, APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE, APP_BOXEL_REALMS_EVENT_TYPE, + APP_BOXEL_REALM_EVENT_ROOM_TYPE, APP_BOXEL_REALM_SERVERS_EVENT_TYPE, REALMS_LIST_UPDATED_EVENT_TYPE, APP_BOXEL_WORKSPACE_FAVORITES_EVENT_TYPE, @@ -74,6 +75,8 @@ import { SLIDING_SYNC_AUTH_ROOM_LIST_NAME, SLIDING_SYNC_AUTH_ROOM_TIMELINE_LIMIT, SLIDING_SYNC_LIST_RANGE_END, + SLIDING_SYNC_REALM_EVENT_ROOM_LIST_NAME, + SLIDING_SYNC_REALM_EVENT_ROOM_LIST_RANGE, SLIDING_SYNC_TIMEOUT, type LLMMode, getToolRequests, @@ -1616,6 +1619,8 @@ export default class MatrixService extends Service { ranges: [[0, SLIDING_SYNC_LIST_RANGE_END]], filters: { is_dm: false, + // A world-readable realm's event room isn't a DM either. + not_room_types: [APP_BOXEL_REALM_EVENT_ROOM_TYPE], }, timeline_limit: timelineLimit, required_state: [['*', '*']], @@ -1636,6 +1641,19 @@ export default class MatrixService extends Service { timeline_limit: SLIDING_SYNC_AUTH_ROOM_TIMELINE_LIMIT, required_state: [['*', '*']], }); + // The rooms world-readable realms send their realm events into. Like the + // session rooms they aren't AI rooms, so their events are relayed as realm + // events. + lists.set(SLIDING_SYNC_REALM_EVENT_ROOM_LIST_NAME, { + ranges: [SLIDING_SYNC_REALM_EVENT_ROOM_LIST_RANGE], + filters: { + room_types: [APP_BOXEL_REALM_EVENT_ROOM_TYPE], + }, + timeline_limit: SLIDING_SYNC_AUTH_ROOM_TIMELINE_LIMIT, + // Only the user's own membership, which event handling checks, rather + // than every member of a room many users share. + required_state: [['m.room.member', '$ME']], + }); this.slidingSync = new this.matrixSdkLoader.SlidingSync( this.client.baseUrl, lists, diff --git a/packages/host/app/services/message-service.ts b/packages/host/app/services/message-service.ts index ca325217e2e..bffbfa785d1 100644 --- a/packages/host/app/services/message-service.ts +++ b/packages/host/app/services/message-service.ts @@ -5,6 +5,7 @@ import { tracked } from '@glimmer/tracking'; import { canonicalRealmIdentifier } from '../lib/realm-utils'; import type NetworkService from './network'; +import type RealmServerService from './realm-server'; import type SessionService from './session'; import type { RealmEventContent } from '@cardstack/base/matrix-event'; @@ -12,6 +13,7 @@ export default class MessageService extends Service { @tracked listenerCallbacks: Map void)[]> = new Map(); @service declare private network: NetworkService; + @service declare private realmServer: RealmServerService; @service declare private session: SessionService; constructor(...args: ConstructorParameters) { @@ -50,6 +52,8 @@ export default class MessageService extends Service { let key = this.keyFor(realmURL); if (!this.listenerCallbacks.has(key)) { this.listenerCallbacks.set(key, []); + // A world-readable realm's events arrive in a room of its own. + this.realmServer.joinRealmEventRoom(key); } // TODO might want to consider making separate subscription methods so that diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index fd8387048fc..f93c29110cf 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -145,6 +145,12 @@ export default class RealmServerService private _ready = new Deferred(); private eventSubscribers: Map = new Map(); + // A world-readable realm sends its realm events into a room of its own, + // which the host joins when something first subscribes to the realm (see + // `joinRealmEventRoom`). The realms still to be asked about, and every realm + // asked about this session. + private realmEventRoomsPending = new Set(); + private realmEventRoomsRequested = new Set(); constructor(owner: Owner) { super(owner); @@ -178,6 +184,8 @@ export default class RealmServerService 0, this.unreachableRealmServersList.length, ); + this.realmEventRoomsPending.clear(); + this.realmEventRoomsRequested.clear(); // Do NOT clear eventSubscribers here: subscriber *wiring* is app-scoped. // Services (e.g. BillingService) call subscribeEvent() once in their // constructor, for the app's lifetime. Logout is an in-app reset, not a @@ -696,8 +704,77 @@ export default class RealmServerService for (let [realmURL, token] of Object.entries(tokens)) { this.realm.getOrCreateRealmResource(realmURL, token); } + // Realms subscribed to before there was a session to join with. + void this.joinRealmEventRoomsTask.perform(); } + // Joins the room the given realm sends its realm events into, when it is a + // world-readable realm on this realm server; other realms' events arrive in + // the user's session room. Joining once is enough: the host stays in the + // room, and catches up on what it missed through sync. Realms asked for in + // the same turn are asked about together. + joinRealmEventRoom(realmIdentifier: string) { + let realmURL = ensureTrailingSlash( + resolvedRealmURLHref(this.network.virtualNetwork, realmIdentifier), + ); + if (this.realmEventRoomsRequested.has(realmURL)) { + return; + } + this.realmEventRoomsRequested.add(realmURL); + this.realmEventRoomsPending.add(realmURL); + void this.joinRealmEventRoomsTask.perform(); + } + + private joinRealmEventRoomsTask = task({ keepLatest: true }, async () => { + await rawTimeout(0); + // Without a matrix client there's no session to join with. Signing in + // runs this again (see `authenticateToAllAccessibleRealms`). + let client = this.client; + if (!client || this.realmEventRoomsPending.size === 0) { + return; + } + let realms = [...this.realmEventRoomsPending]; + this.realmEventRoomsPending.clear(); + try { + await this.login(); + let response = await this.network.fetch( + `${this.url.href}_realm-event-rooms`, + { + method: 'POST', + headers: { + Accept: SupportedMimeType.JSON, + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.token}`, + }, + body: JSON.stringify({ realms }), + }, + ); + if (!response.ok) { + throw new Error(`${response.status} - ${await response.text()}`); + } + let { rooms } = (await response.json()) as { + rooms?: Record; + }; + let roomIds = [...new Set(Object.values(rooms ?? {}))]; + if (roomIds.length === 0) { + return; + } + let { joined_rooms } = await client.getJoinedRooms(); + let joinedRooms = new Set(joined_rooms ?? []); + for (let roomId of roomIds) { + if (!joinedRooms.has(roomId)) { + await client.joinRoom(roomId); + } + } + } catch (e) { + // Asked about again the next time this runs. + for (let realm of realms) { + this.realmEventRoomsPending.add(realm); + } + console.warn(`Unable to join the realm event rooms of ${realms}`, e); + } + }); + private async ensureJoinedSessionRoom(tokens: { [realmUrl: string]: string; }) { diff --git a/packages/host/config/schema/1792465777331_schema.sql b/packages/host/config/schema/1792619085178_schema.sql similarity index 100% rename from packages/host/config/schema/1792465777331_schema.sql rename to packages/host/config/schema/1792619085178_schema.sql diff --git a/packages/host/tests/helpers/index.gts b/packages/host/tests/helpers/index.gts index 0c4bd6267ff..82d89503730 100644 --- a/packages/host/tests/helpers/index.gts +++ b/packages/host/tests/helpers/index.gts @@ -101,6 +101,7 @@ export { resetCatalogRealmURL, setRealmArchived, setRealmAuthFailure, + setRealmEventRoom, setupAuthEndpoints, setupPublicReadableRealms, setCatalogRealmURL, diff --git a/packages/host/tests/helpers/realm-server-mock/index.ts b/packages/host/tests/helpers/realm-server-mock/index.ts index e7957f390a7..80d0a41b437 100644 --- a/packages/host/tests/helpers/realm-server-mock/index.ts +++ b/packages/host/tests/helpers/realm-server-mock/index.ts @@ -58,6 +58,7 @@ function ensureRealmServerMockState( realmPermissions, publicReadableRealms: new Map(), archivedRealms: new Map(), + realmEventRooms: new Map(), handler, ensureSessionRoom: sessionRoomEnsurer, }; @@ -125,6 +126,14 @@ export function setRealmArchived(realmURL: string, archived: boolean) { // Simulate a trusted realm server going unreachable (or recovering) at its // `_realm-auth` endpoint, so boot-assembly graceful-degradation and retry can // be exercised deterministically. +// Makes `/_realm-event-rooms` answer for the given world-readable realm with +// the given room, the one the host should join for its realm events. +export function setRealmEventRoom(realmURL: string, roomId: string) { + let network = getService('network') as NetworkService; + let state = ensureRealmServerMockState(network); + state.realmEventRooms.set(ensureTrailingSlash(realmURL), roomId); +} + export function setRealmAuthFailure(shouldFail: boolean) { let network = getService('network') as NetworkService; let state = ensureRealmServerMockState(network); diff --git a/packages/host/tests/helpers/realm-server-mock/routes.ts b/packages/host/tests/helpers/realm-server-mock/routes.ts index aa2b1bf7980..03bcd88c0dd 100644 --- a/packages/host/tests/helpers/realm-server-mock/routes.ts +++ b/packages/host/tests/helpers/realm-server-mock/routes.ts @@ -630,6 +630,24 @@ function registerAuthRoutes() { }, }); + registerRealmServerRoute({ + path: '/_realm-event-rooms', + handler: async (req, _url, state: RealmServerMockState) => { + let { realms } = (await req.json()) as { realms: string[] }; + let rooms: Record = {}; + for (let realm of realms) { + let roomId = state.realmEventRooms.get(ensureTrailingSlash(realm)); + if (roomId) { + rooms[realm] = roomId; + } + } + return new Response(JSON.stringify({ rooms }), { + status: 200, + headers: { 'content-type': SupportedMimeType.JSON }, + }); + }, + }); + registerRealmServerRoute({ path: '/_server-session', handler: async (req) => { diff --git a/packages/host/tests/helpers/realm-server-mock/types.ts b/packages/host/tests/helpers/realm-server-mock/types.ts index 285af6e94d9..12d8206a5dc 100644 --- a/packages/host/tests/helpers/realm-server-mock/types.ts +++ b/packages/host/tests/helpers/realm-server-mock/types.ts @@ -16,6 +16,10 @@ export type RealmServerMockState = { // Realms archived via POST /_archive-realm, keyed by normalized URL. The // value carries the archived-at timestamp surfaced by GET /_archived-realms. archivedRealms: Map; + // The rooms `/_realm-event-rooms` answers with, keyed by normalized realm + // URL. Empty unless a test sets one, so the host joins no room and every + // realm's events keep arriving in its session room. + realmEventRooms: Map; mountedVirtualNetwork?: unknown; ensureSessionRoom?: EnsureSessionRoom; // When true, `_realm-auth` responds 503 — used to simulate a trusted realm diff --git a/packages/host/tests/integration/message-service-test.ts b/packages/host/tests/integration/message-service-test.ts index fbc4916b9ca..d35b650dd73 100644 --- a/packages/host/tests/integration/message-service-test.ts +++ b/packages/host/tests/integration/message-service-test.ts @@ -1,3 +1,5 @@ +import { waitUntil } from '@ember/test-helpers'; + import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; @@ -5,7 +7,11 @@ import { baseRealm } from '@cardstack/runtime-common'; import ENV from '@cardstack/host/config/environment'; -import { testRealmURL, setupIntegrationTestRealm } from '../helpers'; +import { + testRealmURL, + setRealmEventRoom, + setupIntegrationTestRealm, +} from '../helpers'; import { setupMockMatrix } from '../helpers/mock-matrix'; import { setupRenderingTest } from '../helpers/setup'; @@ -95,3 +101,39 @@ module('Integration | message-service | realm subscriptions', function (hooks) { } }); }); + +// A world-readable realm sends its realm events into a room of its own, which +// the host joins when something first subscribes to the realm. +module('Integration | message-service | realm event rooms', function (hooks) { + setupRenderingTest(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + autostart: true, + }); + + hooks.beforeEach(async function () { + await setupIntegrationTestRealm({ mockMatrixUtils, contents: {} }); + }); + + test('subscribing to a world-readable realm joins the room its events go to', async function (assert) { + let publicRealmURL = 'http://localhost:4205/public/'; + let roomId = '!public-realm-events:localhost'; + setRealmEventRoom(publicRealmURL, roomId); + let messageService = getService('message-service'); + + let unsubscribe = messageService.subscribe(publicRealmURL, () => {}); + try { + await waitUntil(() => mockMatrixUtils.getRoomIds().includes(roomId), { + timeoutMessage: `the host did not join ${roomId}`, + }); + assert.ok( + mockMatrixUtils.getRoomIds().includes(roomId), + 'the host joined the realm event room', + ); + } finally { + unsubscribe(); + } + }); +}); diff --git a/packages/postgres/migrations/1792619085178_create-realm-event-rooms.js b/packages/postgres/migrations/1792619085178_create-realm-event-rooms.js new file mode 100644 index 00000000000..bd18d0fdc16 --- /dev/null +++ b/packages/postgres/migrations/1792619085178_create-realm-event-rooms.js @@ -0,0 +1,21 @@ +// The Matrix room a world-readable realm sends its realm events into, shared by +// every host that has joined it. A realm has at most one; its row is written +// the first time a host asks for the room. + +exports.up = (pgm) => { + pgm.createTable('realm_event_rooms', { + realm_url: { + type: 'varchar', + notNull: true, + primaryKey: true, + }, + room_id: { + type: 'varchar', + notNull: true, + }, + }); +}; + +exports.down = (pgm) => { + pgm.dropTable('realm_event_rooms', { ifExists: true }); +}; diff --git a/packages/postgres/scripts/schema-dump.sh b/packages/postgres/scripts/schema-dump.sh index 83e77530011..f769bb5ccea 100755 --- a/packages/postgres/scripts/schema-dump.sh +++ b/packages/postgres/scripts/schema-dump.sh @@ -41,6 +41,7 @@ docker exec boxel-pg pg_dump \ --exclude-table-and-children=billable_call_reservations \ --exclude-table-and-children=claimed_domains_for_sites \ --exclude-table-and-children=session_rooms \ + --exclude-table-and-children=realm_event_rooms \ --exclude-table-and-children=host_shell_generation \ --no-tablespaces \ --no-table-access-method \ diff --git a/packages/realm-server/handlers/handle-realm-event-rooms.ts b/packages/realm-server/handlers/handle-realm-event-rooms.ts new file mode 100644 index 00000000000..3562e01ce0b --- /dev/null +++ b/packages/realm-server/handlers/handle-realm-event-rooms.ts @@ -0,0 +1,98 @@ +import type Koa from 'koa'; +import { + fetchRealmEventRoom, + logger, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { CreateRoutesArgs } from '../routes.ts'; +import { normalizeRealmURL } from '../utils/realm-url.ts'; +import { + fetchRequestFromContext, + sendResponseForBadRequest, + setContextResponse, +} from '../middleware/index.ts'; + +const log = logger('realm:events'); + +// More realms than a host has open at once. +const MAX_REALMS_PER_REQUEST = 200; + +// Answers a signed-in host with the rooms that the world-readable realms it +// names send their realm events into, so it can join them. A realm's room is +// created the first time a host asks for it. A realm that isn't +// world-readable, or that this server doesn't serve, is left out of the +// answer: its events reach the host through the user's session room, if they +// reach it at all. +// +// Body: `{ realms: string[] }`. Answer: `{ rooms: { [realm]: roomId } }`, +// keyed by each realm as the request spelled it. +export default function handleRealmEventRooms({ + dbAdapter, + reconciler, +}: CreateRoutesArgs): (ctxt: Koa.Context, next: Koa.Next) => Promise { + return async function (ctxt: Koa.Context, _next: Koa.Next) { + let request = await fetchRequestFromContext(ctxt); + let body: { realms?: unknown }; + try { + body = JSON.parse(await request.text()); + } catch { + await sendResponseForBadRequest(ctxt, 'Request body is not valid JSON'); + return; + } + let realms = body?.realms; + if ( + !Array.isArray(realms) || + !realms.every((realm) => typeof realm === 'string') + ) { + await sendResponseForBadRequest( + ctxt, + 'Request body must be { "realms": string[] }', + ); + return; + } + if (realms.length > MAX_REALMS_PER_REQUEST) { + await sendResponseForBadRequest( + ctxt, + `A request may name at most ${MAX_REALMS_PER_REQUEST} realms`, + ); + return; + } + + let rooms: Record = {}; + await Promise.all( + (realms as string[]).map(async (realm) => { + let realmURL = normalizeRealmURL(realm)?.href; + if (!realmURL) { + return; + } + try { + let { worldReadable, roomId } = await fetchRealmEventRoom( + dbAdapter, + realmURL, + ); + if (!worldReadable) { + return; + } + if (!roomId) { + // Only the realm's own matrix user may send in its room, so the + // realm creates it. + let mounted = await reconciler.lookupOrMount(realmURL); + roomId = (await mounted?.ensureRealmEventRoom()) ?? null; + } + if (roomId) { + rooms[realm] = roomId; + } + } catch (e) { + log.error(`Unable to provide the realm event room of ${realmURL}`, e); + } + }), + ); + + await setContextResponse( + ctxt, + new Response(JSON.stringify({ rooms }), { + headers: { 'content-type': SupportedMimeType.JSON }, + }), + ); + }; +} diff --git a/packages/realm-server/node-realm.ts b/packages/realm-server/node-realm.ts index 4e90ae9c76e..399065b38c2 100644 --- a/packages/realm-server/node-realm.ts +++ b/packages/realm-server/node-realm.ts @@ -13,6 +13,7 @@ import { type ResponseWithNodeStream, type TokenClaims, clearSessionRoom, + fetchRealmEventRoom, fetchRealmSessionRooms, isRealmServerNotInRoomError, } from '@cardstack/runtime-common'; @@ -452,6 +453,40 @@ export class NodeAdapter implements RealmAdapter { return; } + // A world-readable realm's events go once into its own room, which every + // host using the realm joins, instead of into the session room of every + // user who can read it. Until a host asks for that room it doesn't exist, + // and no one can be listening. + let realmEventRoom; + try { + realmEventRoom = await fetchRealmEventRoom(dbAdapter, realmUrl); + } catch (e) { + realmEventsLog.error('Error looking up the realm event room', e); + return; + } + if (realmEventRoom.worldReadable) { + if (!realmEventRoom.roomId) { + realmEventsLog.debug( + `${realmUrl} has no realm event room yet, skipping broadcast`, + ); + return; + } + try { + await matrixClient.sendEvent( + realmEventRoom.roomId, + APP_BOXEL_REALM_EVENT_TYPE, + eventWithRealmURL, + ); + } catch (e) { + realmEventsLog.error( + `Unable to send event in realm event room ${realmEventRoom.roomId} for ${realmUrl}`, + event, + e, + ); + } + return; + } + let dmRooms = await this.waitForSessionRooms(dbAdapter, realmUrl); realmEventsLog.debug('Sending to dm rooms', Object.values(dmRooms)); diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index eee42268efb..74c7a36dcfc 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -61,6 +61,7 @@ import handleOpenRouterPassthrough from './handlers/handle-openrouter-passthroug import handlePostDeployment from './handlers/handle-post-deployment.ts'; import { handleCheckBoxelDomainAvailabilityRequest } from './handlers/handle-check-boxel-domain-availability.ts'; import handleRealmAuth from './handlers/handle-realm-auth.ts'; +import handleRealmEventRooms from './handlers/handle-realm-event-rooms.ts'; import handleSetRealmCatalog from './handlers/handle-set-realm-catalog.ts'; import handleDelegateSession from './handlers/handle-delegate-session.ts'; import handleWorkerRequest from './handlers/handle-worker-request.ts'; @@ -618,6 +619,11 @@ export function createRoutes(args: CreateRoutesArgs) { jwtMiddleware(args.realmSecretSeed, args.dbAdapter), handleRealmAuth(args), ); + router.post( + '/_realm-event-rooms', + jwtMiddleware(args.realmSecretSeed, args.dbAdapter), + handleRealmEventRooms(args), + ); // Shared-secret authenticated (HMAC over body + timestamp); auth is handled // inside the handler because the signature covers the request body. router.post('/_delegate-session', handleDelegateSession(args)); diff --git a/packages/realm-server/scripts/load-harness/lib/realm-events.ts b/packages/realm-server/scripts/load-harness/lib/realm-events.ts index 1025d4aa581..0fdfa751c97 100644 --- a/packages/realm-server/scripts/load-harness/lib/realm-events.ts +++ b/packages/realm-server/scripts/load-harness/lib/realm-events.ts @@ -1,8 +1,9 @@ // Subscribe to a realm's index events the way the host does. // -// The realm server broadcasts `app.boxel.realm-event` into each user's DM -// session room over Matrix, and the host relays what arrives there into its -// live-search resources. A driver that wants to behave like a browser has to +// The realm server broadcasts `app.boxel.realm-event` over Matrix: a +// world-readable realm's events into one room for the realm that every host +// using it joins, and any other realm's into the DM session room of each user +// it grants. The host relays what arrives into its live-search resources. A driver that wants to behave like a browser has to // listen on that channel rather than infer invalidation from its own writes — // which is the difference between modelling the fan-out and measuring it. // @@ -73,6 +74,60 @@ export async function joinInvitedRooms({ }; } +// Joins the rooms the given world-readable realms send their realm events +// into, the way the host does when it first subscribes to a realm. The realm +// server answers only for world-readable realms, so a realm that isn't joins +// nothing and its events arrive in the session room. Returns how many rooms +// were joined. +export async function joinRealmEventRooms({ + realmServerUrl, + serverToken, + matrixUrl, + accessToken, + realms, +}: { + realmServerUrl: string; + serverToken: string; + matrixUrl: string; + accessToken: string; + realms: string[]; +}): Promise { + let response = await fetch( + `${realmServerUrl.replace(/\/$/, '')}/_realm-event-rooms`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: serverToken, + }, + body: JSON.stringify({ realms }), + }, + ); + if (!response.ok) { + throw new Error(`_realm-event-rooms failed: ${response.status}`); + } + let { rooms } = (await response.json()) as { + rooms?: Record; + }; + let roomIds = [...new Set(Object.values(rooms ?? {}))]; + let base = matrixUrl.replace(/\/$/, ''); + for (let roomId of roomIds) { + let join = await fetch( + `${base}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`, + { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + body: '{}', + }, + ); + if (!join.ok) { + throw new Error(`join ${roomId} failed: ${join.status}`); + } + } + return roomIds.length; +} + export async function initialSyncToken({ matrixUrl, accessToken, diff --git a/packages/realm-server/scripts/load-harness/run-load.ts b/packages/realm-server/scripts/load-harness/run-load.ts index af8a52ac6d0..366f256d622 100644 --- a/packages/realm-server/scripts/load-harness/run-load.ts +++ b/packages/realm-server/scripts/load-harness/run-load.ts @@ -51,6 +51,7 @@ import { eventCannotMatch, initialSyncToken, joinInvitedRooms, + joinRealmEventRooms, streamRealmEvents, type RealmEvent, } from './lib/realm-events.ts'; @@ -580,10 +581,9 @@ async function modelCall(session: Session): Promise { } } -// One Matrix long-poll per session, fanned out to that session's reader. The -// realm broadcasts into each user's own DM session room, so every simulated -// user needs its own stream — which is also what makes the event volume here -// comparable to the cohort's. +// One Matrix long-poll per session, fanned out to that session's reader. Every +// simulated user reads its own stream, as each browser does, whether the realm +// broadcasts into each user's DM session room or into one room they all share. let subscriptions = new Map void)[]>(); let subscribeAbort = new AbortController(); @@ -610,10 +610,19 @@ function subscribeSession( matrixUrl: args.matrixUrl, accessToken: session.accessToken, }); + // A world-readable realm's events go to a room of its own instead, + // which the host joins when it first subscribes to the realm. + joined += await joinRealmEventRooms({ + realmServerUrl: args.realmServerUrl, + serverToken: session.serverToken, + matrixUrl: args.matrixUrl, + accessToken: session.accessToken, + realms: [realmUrl], + }); if (joined === 0) { stats.sessionsWithoutRooms++; console.error( - ` ${session.username} has no realm session room, so it will receive\n` + + ` ${session.username} is in no room realm events go to, so it will receive\n` + ` no events — a low re-run count would mean nothing.`, ); } diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index a2a6528f924..19e7f795e86 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -2355,6 +2355,7 @@ export function setupMatrixRoom( }); let testAuthRoomId: string | undefined; + let testRealmEventRoomId: string | undefined; hooks.beforeEach(async function () { await matrixClient.login(); @@ -2388,15 +2389,52 @@ export function setupMatrixRoom( } testAuthRoomId = payload.sessionRoom; + + // A world-readable test realm sends its realm events into a room of its + // own instead, which a host joins the way this does. + let eventRoomsResponse = await ( + realmSetup.serverRequest ?? realmSetup.request + ) + .post('/_realm-event-rooms') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set('Authorization', jwt) + .send(JSON.stringify({ realms: [realmSetup.testRealm.url] })); + if (eventRoomsResponse.status !== 200) { + throw new Error( + `Realm server did not answer /_realm-event-rooms: ${eventRoomsResponse.status} ${eventRoomsResponse.text}`, + ); + } + testRealmEventRoomId = ( + eventRoomsResponse.body as { rooms: Record } + ).rooms[realmSetup.testRealm.url]; + if (testRealmEventRoomId && !rooms.includes(testRealmEventRoomId)) { + await matrixClient.joinRoom(testRealmEventRoomId); + } }); return { matrixClient, - // Every event the room received at or after `since`, however many that - // is. The comparison is inclusive so an event sent in the same millisecond - // the caller recorded its start time still counts. + // Every event the realm's events reach this user through at or after + // `since`, however many that is, newest first: the session room, and the + // realm's event room when it is world-readable. The comparison is + // inclusive so an event sent in the same millisecond the caller recorded + // its start time still counts. getMessagesSince: async function (since: number) { - return await matrixClient.roomMessagesSince(testAuthRoomId!, since); + let messages = await matrixClient.roomMessagesSince( + testAuthRoomId!, + since, + ); + if (testRealmEventRoomId) { + messages = [ + ...messages, + ...(await matrixClient.roomMessagesSince( + testRealmEventRoomId, + since, + )), + ].sort((a, b) => b.origin_server_ts - a.origin_server_ts); + } + return messages; }, }; } diff --git a/packages/realm-server/tests/node-realm-test.ts b/packages/realm-server/tests/node-realm-test.ts index a1e8631c9a7..23b53bade10 100644 --- a/packages/realm-server/tests/node-realm-test.ts +++ b/packages/realm-server/tests/node-realm-test.ts @@ -7,6 +7,7 @@ import type { PgAdapter } from '@cardstack/postgres'; import { fetchSessionRoom, insertPermissions, + insertRealmEventRoom, upsertSessionRoom, } from '@cardstack/runtime-common'; import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; @@ -147,6 +148,90 @@ module(basename(import.meta.filename), function (hooks) { assert.true(true, 'broadcast resolves even if stale room cleanup fails'); }); + + function recordingMatrixClient() { + let sentTo: string[] = []; + let matrixClient = { + login: async () => undefined, + getUserId: () => '@realm_server:localhost', + sendEvent: async (roomId: string) => { + sentTo.push(roomId); + return '$event'; + }, + } as unknown as MatrixClient; + return { matrixClient, sentTo }; + } + + test("sends a world-readable realm's event once, into the realm's event room", async function (assert) { + await insertUser( + dbAdapter, + '@alice:localhost', + 'cus_alice', + 'alice@example.com', + ); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!alice:localhost'); + await insertUser(dbAdapter, '@bob:localhost', 'cus_bob', 'bob@example.com'); + await upsertSessionRoom(dbAdapter, '@bob:localhost', '!bob:localhost'); + await insertPermissions(dbAdapter, realmURL, { + '*': ['read'], + '@alice:localhost': ['read', 'write'], + }); + await insertRealmEventRoom(dbAdapter, realmURL.href, '!events:localhost'); + + let { matrixClient, sentTo } = recordingMatrixClient(); + await new NodeAdapter('/tmp').broadcastRealmEvent( + makeEvent(), + realmURL.href, + matrixClient, + dbAdapter, + ); + + assert.deepEqual( + sentTo, + ['!events:localhost'], + 'sent to the realm event room and to no session room', + ); + }); + + test('sends nothing for a world-readable realm that has no event room yet', async function (assert) { + await insertUser( + dbAdapter, + '@alice:localhost', + 'cus_alice', + 'alice@example.com', + ); + await upsertSessionRoom(dbAdapter, '@alice:localhost', '!alice:localhost'); + await insertPermissions(dbAdapter, realmURL, { '*': ['read'] }); + + let { matrixClient, sentTo } = recordingMatrixClient(); + await new NodeAdapter('/tmp').broadcastRealmEvent( + makeEvent(), + realmURL.href, + matrixClient, + dbAdapter, + ); + + assert.deepEqual(sentTo, [], 'no room was sent to'); + }); + + test("sends a realm's event to session rooms once it is no longer world-readable", async function (assert) { + await insertSessionForAlice(); + await insertRealmEventRoom(dbAdapter, realmURL.href, '!events:localhost'); + + let { matrixClient, sentTo } = recordingMatrixClient(); + await new NodeAdapter('/tmp').broadcastRealmEvent( + makeEvent(), + realmURL.href, + matrixClient, + dbAdapter, + ); + + assert.deepEqual( + sentTo, + [staleRoomId], + "sent to alice's session room and not to the realm's old event room", + ); + }); }); module(`${basename(import.meta.filename)} | file stat probing`, function () { diff --git a/packages/realm-server/tests/realm-event-rooms-test.ts b/packages/realm-server/tests/realm-event-rooms-test.ts new file mode 100644 index 00000000000..3d65c66d64c --- /dev/null +++ b/packages/realm-server/tests/realm-event-rooms-test.ts @@ -0,0 +1,155 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import type { SuperTest, Test as SupertestTest } from 'supertest'; +import { basename } from 'path'; + +import type { PgAdapter } from '@cardstack/postgres'; +import { insertPermissions } from '@cardstack/runtime-common'; +import { passwordFromSeed } from '@cardstack/runtime-common/matrix-client'; +import { + APP_BOXEL_REALM_EVENT_ROOM_TYPE, + APP_BOXEL_REALM_EVENT_TYPE, +} from '@cardstack/runtime-common/matrix-constants'; + +import { + matrixURL, + realmSecretSeed, + setupPermissionedRealmCached, + testRealmHref, +} from './helpers/index.ts'; +import { createJWT as createRealmServerJWT } from '../utils/jwt.ts'; + +module(basename(import.meta.filename), function () { + module('realm event rooms handler', function (hooks) { + let dbAdapter: PgAdapter; + let request: SuperTest; + const matrixUserId = '@test_realm:localhost'; + + setupPermissionedRealmCached(hooks, { + fixture: 'blank', + permissions: { + '*': ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + onRealmSetup: ({ dbAdapter: adapter, request: req }) => { + dbAdapter = adapter; + request = req; + }, + }); + + function realmEventRooms(realms: string[]) { + return request + .post('/_realm-event-rooms') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'server-session-room' }, + realmSecretSeed, + )}`, + ) + .send(JSON.stringify({ realms })); + } + + // A client for a user other than the realm's, the way a host is one. + async function loginAsMember() { + let response = await fetch( + new URL('_matrix/client/v3/login', matrixURL), + { + method: 'POST', + body: JSON.stringify({ + identifier: { type: 'm.id.user', user: 'test_realm' }, + password: await passwordFromSeed('test_realm', realmSecretSeed), + type: 'm.login.password', + }), + }, + ); + let { access_token } = (await response.json()) as { + access_token: string; + }; + return (path: string, init: RequestInit = {}) => + fetch(new URL(`_matrix/client/v3/${path}`, matrixURL), { + ...init, + headers: { Authorization: `Bearer ${access_token}` }, + }); + } + + test('answers a world-readable realm with the same room each time', async function (assert) { + let first = await realmEventRooms([testRealmHref]); + assert.strictEqual(first.status, 200, 'HTTP 200 status'); + let roomId = first.body.rooms[testRealmHref]; + assert.ok(roomId, 'the realm has an event room'); + + let second = await realmEventRooms([testRealmHref]); + assert.strictEqual( + second.body.rooms[testRealmHref], + roomId, + 'asking again answers with the same room', + ); + }); + + test('leaves out a realm that is not world-readable', async function (assert) { + let privateRealmURL = new URL('http://127.0.0.1:4444/private/'); + await insertPermissions(dbAdapter, privateRealmURL, { + [matrixUserId]: ['read', 'write'], + }); + + let response = await realmEventRooms([ + privateRealmURL.href, + 'http://127.0.0.1:4444/unknown/', + ]); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.deepEqual(response.body, { rooms: {} }, 'no room is answered'); + }); + + test('rejects a request without a session', async function (assert) { + let response = await request + .post('/_realm-event-rooms') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ realms: [testRealmHref] })); + + assert.strictEqual(response.status, 401, 'HTTP 401 status'); + }); + + test('rejects a body without a list of realms', async function (assert) { + let response = await realmEventRooms('not a list' as unknown as string[]); + + assert.strictEqual(response.status, 400, 'HTTP 400 status'); + }); + + test('lets any user join the room, and only the realm send in it', async function (assert) { + let roomId: string = (await realmEventRooms([testRealmHref])).body.rooms[ + testRealmHref + ]; + let member = await loginAsMember(); + + let join = await member(`join/${encodeURIComponent(roomId)}`, { + method: 'POST', + body: '{}', + }); + assert.strictEqual(join.status, 200, 'a user who was not invited joins'); + + let create = await member( + `rooms/${encodeURIComponent(roomId)}/state/m.room.create/`, + ); + assert.strictEqual( + ((await create.json()) as { type?: string }).type, + APP_BOXEL_REALM_EVENT_ROOM_TYPE, + 'the room has the realm event room type', + ); + + let send = await member( + `rooms/${encodeURIComponent(roomId)}/send/${APP_BOXEL_REALM_EVENT_TYPE}/forged-${Date.now()}`, + { method: 'PUT', body: JSON.stringify({ eventName: 'index' }) }, + ); + assert.strictEqual( + send.status, + 403, + 'a member may not send a realm event into the room', + ); + }); + }); +}); diff --git a/packages/realm-server/tests/session-room-queries-test.ts b/packages/realm-server/tests/session-room-queries-test.ts index 9bc2d561caa..ecf9e6ddd6a 100644 --- a/packages/realm-server/tests/session-room-queries-test.ts +++ b/packages/realm-server/tests/session-room-queries-test.ts @@ -5,8 +5,10 @@ import type { PgAdapter } from '@cardstack/postgres'; import { insertPermissions } from '@cardstack/runtime-common'; import { clearSessionRoom, + fetchRealmEventRoom, fetchSessionRoom, fetchRealmSessionRooms, + insertRealmEventRoom, upsertSessionRoom, } from '@cardstack/runtime-common/db-queries/session-room-queries'; import { setupDB, insertUser } from './helpers/index.ts'; @@ -199,8 +201,7 @@ module(basename(import.meta.filename), function () { ); }); - test('returns all users with session rooms when the realm is world-readable', async function (assert) { - // Create multiple users with session rooms + test('a world-readable grant adds no users', async function (assert) { await insertUser( dbAdapter, '@alice:localhost', @@ -213,70 +214,6 @@ module(basename(import.meta.filename), function () { '!room-alice:localhost', ); - await insertUser( - dbAdapter, - '@bob:localhost', - 'cus_bob', - 'bob@example.com', - ); - await upsertSessionRoom( - dbAdapter, - '@bob:localhost', - '!room-bob:localhost', - ); - - await insertUser( - dbAdapter, - '@charlie:localhost', - 'cus_charlie', - 'charlie@example.com', - ); - await upsertSessionRoom( - dbAdapter, - '@charlie:localhost', - '!room-charlie:localhost', - ); - - // World-readable realm: username='*' with read=true - // No per-user permission rows for alice, bob, or charlie - await insertPermissions(dbAdapter, realmURL, { - '*': ['read'], - }); - - let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); - - assert.deepEqual( - result, - { - '@alice:localhost': '!room-alice:localhost', - '@bob:localhost': '!room-bob:localhost', - '@charlie:localhost': '!room-charlie:localhost', - }, - 'returns ALL users with session rooms for world-readable realm', - ); - }); - - test('world-readable realm still excludes users without session rooms', async function (assert) { - await insertUser( - dbAdapter, - '@alice:localhost', - 'cus_alice', - 'alice@example.com', - ); - await upsertSessionRoom( - dbAdapter, - '@alice:localhost', - '!room-alice:localhost', - ); - - // bob has no session room - await insertUser( - dbAdapter, - '@bob:localhost', - 'cus_bob', - 'bob@example.com', - ); - await insertPermissions(dbAdapter, realmURL, { '*': ['read'], }); @@ -285,10 +222,8 @@ module(basename(import.meta.filename), function () { assert.deepEqual( result, - { - '@alice:localhost': '!room-alice:localhost', - }, - 'excludes users without session rooms even for world-readable realm', + {}, + "a world-readable realm's events go to its own room, not to session rooms", ); }); @@ -340,7 +275,7 @@ module(basename(import.meta.filename), function () { ); }); - test('combines explicit permissions and world-readable access', async function (assert) { + test('returns only users with their own grant on a world-readable realm', async function (assert) { await insertUser( dbAdapter, '@alice:localhost', @@ -365,7 +300,6 @@ module(basename(import.meta.filename), function () { '!room-bob:localhost', ); - // alice has explicit write permission AND the realm is world-readable await insertPermissions(dbAdapter, realmURL, { '*': ['read'], '@alice:localhost': ['read', 'write'], @@ -373,17 +307,69 @@ module(basename(import.meta.filename), function () { let result = await fetchRealmSessionRooms(dbAdapter, realmURL.href); - // Both users should be returned: alice via explicit + world-readable, bob via world-readable - assert.strictEqual(Object.keys(result).length, 2, 'returns 2 users'); - assert.strictEqual( - result['@alice:localhost'], - '!room-alice:localhost', - 'alice is included', + assert.deepEqual( + result, + { '@alice:localhost': '!room-alice:localhost' }, + 'alice is included through her own grant, and bob is not', ); + }); + }); + + module('realm event rooms', function (hooks) { + let dbAdapter: PgAdapter; + const realmURL = new URL('http://127.0.0.1:4444/test/'); + + setupDB(hooks, { + beforeEach: async (_dbAdapter) => { + dbAdapter = _dbAdapter; + }, + }); + + test('a realm that is not world-readable has no event room', async function (assert) { + await insertPermissions(dbAdapter, realmURL, { + '@alice:localhost': ['read', 'write'], + }); + + assert.deepEqual(await fetchRealmEventRoom(dbAdapter, realmURL.href), { + worldReadable: false, + roomId: null, + }); + }); + + test('a world-readable realm has no event room until one is stored', async function (assert) { + await insertPermissions(dbAdapter, realmURL, { '*': ['read'] }); + + assert.deepEqual( + await fetchRealmEventRoom(dbAdapter, realmURL.href), + { worldReadable: true, roomId: null }, + 'no room before one is stored', + ); + + let stored = await insertRealmEventRoom( + dbAdapter, + realmURL.href, + '!events:localhost', + ); + assert.strictEqual(stored, '!events:localhost', 'the room is stored'); + assert.deepEqual( + await fetchRealmEventRoom(dbAdapter, realmURL.href), + { worldReadable: true, roomId: '!events:localhost' }, + 'the stored room is reported', + ); + }); + + test('storing a second room for a realm keeps the first', async function (assert) { + await insertRealmEventRoom(dbAdapter, realmURL.href, '!first:localhost'); + let stored = await insertRealmEventRoom( + dbAdapter, + realmURL.href, + '!second:localhost', + ); + assert.strictEqual( - result['@bob:localhost'], - '!room-bob:localhost', - 'bob is included via world-readable access', + stored, + '!first:localhost', + 'the room stored first is the one returned', ); }); }); diff --git a/packages/runtime-common/db-queries/session-room-queries.ts b/packages/runtime-common/db-queries/session-room-queries.ts index abaef15d054..80f59c2f083 100644 --- a/packages/runtime-common/db-queries/session-room-queries.ts +++ b/packages/runtime-common/db-queries/session-room-queries.ts @@ -67,15 +67,12 @@ export async function clearSessionRoom( } /** - * Returns a mapping of matrix user id to session room id for all known sessions - * that should be notified about changes to the given realm. + * Returns a mapping of matrix user id to session room id for the users with + * their own read or write permission on the given realm. * - * Includes: - * - Users with explicit read/write permissions for this realm. - * - All registered users when the realm is world-readable (username = '*' - * with read = true). This is intentional: world-readable realms are visible - * to everyone, so all users need incremental-index notifications to keep - * their UI up to date. + * A world-readable realm's events don't go to session rooms; they go to the + * realm's event room (see `fetchRealmEventRoom`), so a `'*'` grant adds no one + * here. */ export async function fetchRealmSessionRooms( dbAdapter: DBAdapter, @@ -85,21 +82,12 @@ export async function fetchRealmSessionRooms( 'SELECT u.matrix_user_id, u.session_room_id', 'FROM users u', 'WHERE u.session_room_id IS NOT NULL', - 'AND (', - ' EXISTS (', - ' SELECT 1 FROM realm_user_permissions', - ' WHERE realm_url =', + 'AND EXISTS (', + ' SELECT 1 FROM realm_user_permissions', + ' WHERE realm_url =', param(realmURL), - ' AND username = u.matrix_user_id', - ' AND (read = true OR write = true)', - ' )', - ' OR EXISTS (', - ' SELECT 1 FROM realm_user_permissions', - ' WHERE realm_url =', - param(realmURL), - " AND username = '*'", - ' AND read = true', - ' )', + ' AND username = u.matrix_user_id', + ' AND (read = true OR write = true)', ')', ]); @@ -111,3 +99,59 @@ export async function fetchRealmSessionRooms( } return result; } + +/** + * Reports whether the given realm is world-readable, and the room its realm + * events go to when it has one. A world-readable realm's room is created the + * first time a host asks for it, so until then `roomId` is null. + */ +export async function fetchRealmEventRoom( + dbAdapter: DBAdapter, + realmURL: string, +): Promise<{ worldReadable: boolean; roomId: string | null }> { + let [row] = await query(dbAdapter, [ + 'SELECT EXISTS (', + ' SELECT 1 FROM realm_user_permissions', + ' WHERE realm_url =', + param(realmURL), + " AND username = '*'", + ' AND read = true', + ') AS world_readable,', + '(SELECT room_id FROM realm_event_rooms WHERE realm_url =', + param(realmURL), + ') AS room_id', + ]); + return { + worldReadable: Boolean(row?.world_readable), + roomId: (row?.room_id as string | null | undefined) ?? null, + }; +} + +/** + * Stores the given room as the realm's event room, unless the realm already + * has one, and returns the room that is stored. When two processes create a + * room for the same realm at once, the first stored wins and the other's room + * goes unused. + */ +export async function insertRealmEventRoom( + dbAdapter: DBAdapter, + realmURL: string, + roomId: string, +): Promise { + let rows = await query(dbAdapter, [ + 'WITH inserted AS (', + ' INSERT INTO realm_event_rooms (realm_url, room_id) VALUES (', + param(realmURL), + ',', + param(roomId), + ' ) ON CONFLICT (realm_url) DO NOTHING', + ' RETURNING room_id', + ')', + 'SELECT room_id FROM inserted', + 'UNION ALL', + 'SELECT room_id FROM realm_event_rooms WHERE realm_url =', + param(realmURL), + 'LIMIT 1', + ]); + return rows[0].room_id as string; +} diff --git a/packages/runtime-common/matrix-client.ts b/packages/runtime-common/matrix-client.ts index d3afb0f9628..56b7016d092 100644 --- a/packages/runtime-common/matrix-client.ts +++ b/packages/runtime-common/matrix-client.ts @@ -1,6 +1,7 @@ import { Sha256 } from '@aws-crypto/sha256-js'; import { uint8ArrayToHex } from './index.ts'; import { REALM_ROOM_RETENTION_POLICY_MAX_LIFETIME } from './realm.ts'; +import { APP_BOXEL_REALM_EVENT_ROOM_TYPE } from './matrix-constants.ts'; import type { MatrixEvent } from '@cardstack/base/matrix-event'; type JoinedRoomsResponse = { joined_rooms: string[] }; @@ -258,6 +259,52 @@ export class MatrixClient { return json.room_id; } + // The room a world-readable realm sends its realm events into. Any user on + // the homeserver may join it, and a member sees only what is sent after + // they join. Only this client's user, the realm's, may send in it or change + // its state. + async createRealmEventRoom(realmURL: string): Promise { + let response = await this.request('_matrix/client/v3/createRoom', 'POST', { + body: JSON.stringify({ + preset: 'public_chat', + visibility: 'private', + name: `Realm events: ${realmURL}`, + creation_content: { type: APP_BOXEL_REALM_EVENT_ROOM_TYPE }, + initial_state: [ + { + type: 'm.room.history_visibility', + state_key: '', + content: { history_visibility: 'joined' }, + }, + { + type: 'm.room.retention', + state_key: '', + content: { max_lifetime: REALM_ROOM_RETENTION_POLICY_MAX_LIFETIME }, + }, + ], + power_level_content_override: { + users_default: 0, + events_default: 100, + state_default: 100, + invite: 100, + kick: 100, + ban: 100, + redact: 100, + notifications: { room: 100 }, + }, + }), + }); + let json = (await response.json()) as { room_id: string }; + if (!response.ok) { + throw new Error( + `Unable to create realm event room for ${realmURL}: status ${ + response.status + } - ${JSON.stringify(json)}`, + ); + } + return json.room_id; + } + async setRoomRetentionPolicy(roomId: string, maxLifetimeMs: number) { try { let roomState = await this.request( diff --git a/packages/runtime-common/matrix-constants.ts b/packages/runtime-common/matrix-constants.ts index 3aac459e6e8..f0beaa2b23c 100644 --- a/packages/runtime-common/matrix-constants.ts +++ b/packages/runtime-common/matrix-constants.ts @@ -161,6 +161,10 @@ export const APP_BOXEL_WORKSPACE_FAVORITES_EVENT_TYPE = export const APP_BOXEL_CATALOG_REALMS_EVENT_TYPE = 'app.boxel.catalog-realms'; export const APP_BOXEL_SYSTEM_CARD_EVENT_TYPE = 'app.boxel.system-card'; export const APP_BOXEL_REALM_EVENT_TYPE = 'app.boxel.realm-event'; +// The room type of the room a world-readable realm sends its realm events +// into. One room per realm is shared by every host that has joined it, in +// place of a copy of each event in every user's session room. +export const APP_BOXEL_REALM_EVENT_ROOM_TYPE = 'app.boxel.realm-events'; export const APP_BOXEL_ACTIVE_LLM = 'app.boxel.active-llm'; export const BOT_TRIGGER_EVENT_TYPE = 'app.boxel.bot-trigger'; export const APP_BOXEL_REASONING_CONTENT_KEY = 'app.boxel.reasoning'; @@ -257,6 +261,7 @@ export const DEFAULT_FALLBACK_MODEL_ID = 'openai/gpt-6-luna'; export const SLIDING_SYNC_AI_ROOM_LIST_NAME = 'ai-room'; export const SLIDING_SYNC_AUTH_ROOM_LIST_NAME = 'auth-room'; +export const SLIDING_SYNC_REALM_EVENT_ROOM_LIST_NAME = 'realm-event-room'; export const SLIDING_SYNC_LIST_RANGE_END = 9; // Sliding-sync per-room timeline_limit is sized differently per list, and the // AI-room list also uses a two-stage value: small during the very first sync @@ -287,7 +292,9 @@ export const SLIDING_SYNC_LIST_RANGE_END = 9; // initiation → update → incremental, plus an extra initiation per file in // multi-file batches). The `update` event sits in the middle of that burst and // is what DirectoryResource subscribes to for live file-tree refresh; without -// the higher limit it gets silently dropped from the live Room.timeline. +// the higher limit it gets silently dropped from the live Room.timeline. The +// rooms of world-readable realms (realm-event-room list) receive the same +// bursts, so they use the same limit. export const INITIAL_SLIDING_SYNC_LIST_TIMELINE_LIMIT = 1; // The ai-bot's room list window. Sliding sync reports changes only for rooms // inside the requested range, and matrix-js-sdk does not backfill a `limited` @@ -300,4 +307,10 @@ export const INITIAL_SLIDING_SYNC_LIST_TIMELINE_LIMIT = 1; export const SLIDING_SYNC_AI_BOT_ROOM_LIST_RANGE: [number, number] = [0, 99]; export const SLIDING_SYNC_AI_ROOM_TIMELINE_LIMIT = 20; export const SLIDING_SYNC_AUTH_ROOM_TIMELINE_LIMIT = 20; +// Sliding sync reports changes only for rooms inside a list's range, so the +// realm-event-room window is wide enough for every world-readable realm a host +// has open at once. +export const SLIDING_SYNC_REALM_EVENT_ROOM_LIST_RANGE: [number, number] = [ + 0, 99, +]; export const SLIDING_SYNC_TIMEOUT = 30000; diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 9d95ca35619..5e71c085329 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -577,7 +577,9 @@ import { type PopulateCoordinator, } from './definition-lookup.ts'; import { + fetchRealmEventRoom, fetchSessionRoom, + insertRealmEventRoom, upsertSessionRoom, } from './db-queries/session-room-queries.ts'; import { REALMS_LIST_UPDATED_EVENT_TYPE } from './matrix-constants.ts'; @@ -2888,6 +2890,9 @@ export class Realm { // `/_federated-index-counts` callers share one aggregate instead of each // running the full per-url scan. Nulled once it settles (and on index swap). #indexCountsPromise: Promise | null = null; + // The in-flight creation of this realm's event room, so concurrent callers + // on this process create one room between them. + #realmEventRoomCreation: Promise | null = null; // Cached host routing map, derived from the indexed RealmConfig card. // `getHostRoutingMap()` is called on every host-mode index request // (serve-index), so re-querying the index each time is wasteful — the map @@ -3494,6 +3499,39 @@ export class Realm { return sessionRoom; } + // The room this realm sends its realm events into while it is + // world-readable, created the first time it is asked for. Undefined for a + // realm that isn't world-readable, whose events go to the session rooms of + // the users it grants. + async ensureRealmEventRoom(): Promise { + let { worldReadable, roomId } = await fetchRealmEventRoom( + this.#dbAdapter, + this.url, + ); + if (!worldReadable) { + return undefined; + } + if (roomId) { + return roomId; + } + if (!this.#realmEventRoomCreation) { + this.#realmEventRoomCreation = (async () => { + // A caller that read the table before an earlier creation stored its + // room arrives here after that creation settled. + let stored = await fetchRealmEventRoom(this.#dbAdapter, this.url); + if (stored.roomId) { + return stored.roomId; + } + await this.#matrixClient.login(); + let created = await this.#matrixClient.createRealmEventRoom(this.url); + return await insertRealmEventRoom(this.#dbAdapter, this.url, created); + })().finally(() => { + this.#realmEventRoomCreation = null; + }); + } + return await this.#realmEventRoomCreation; + } + private async readinessCheck( request: Request, requestContext: RequestContext, From 035f235059543ea9133bd8df79d9ebd00f968414 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 21 Oct 2026 23:01:51 -0400 Subject: [PATCH 2/6] Join realm event rooms without making message-service create realm-server Message-service injected realm-server to join a realm's event room, so any subscription created realm-server, whose constructor fetches `_catalog-realms`. A test with no realm-server mock then failed on that fetch. Realm-server now registers with message-service for each realm's first subscription instead. The join no longer checks getJoinedRooms first: a repeat join changes nothing, and the SDK answers it locally once sync has the room. The host test creates the room as the realm's user and waits for the test user's membership, and the sliding-sync test expects the AI list's not_room_types filter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SGrCsG5woc6fYqHu7BBZdY --- packages/host/app/services/message-service.ts | 23 +++++++++++++--- packages/host/app/services/realm-server.ts | 21 +++++++-------- .../matrix-service-sliding-sync-test.ts | 3 ++- .../tests/integration/message-service-test.ts | 26 ++++++++++++++++--- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/host/app/services/message-service.ts b/packages/host/app/services/message-service.ts index bffbfa785d1..85e501fb036 100644 --- a/packages/host/app/services/message-service.ts +++ b/packages/host/app/services/message-service.ts @@ -5,7 +5,6 @@ import { tracked } from '@glimmer/tracking'; import { canonicalRealmIdentifier } from '../lib/realm-utils'; import type NetworkService from './network'; -import type RealmServerService from './realm-server'; import type SessionService from './session'; import type { RealmEventContent } from '@cardstack/base/matrix-event'; @@ -13,8 +12,10 @@ export default class MessageService extends Service { @tracked listenerCallbacks: Map void)[]> = new Map(); @service declare private network: NetworkService; - @service declare private realmServer: RealmServerService; @service declare private session: SessionService; + // App-scoped wiring, like RealmServerService.eventSubscribers: a service + // registers once in its constructor, so logout leaves these in place. + private firstSubscriptionListeners = new Set<(realm: string) => void>(); constructor(...args: ConstructorParameters) { super(...args); @@ -52,8 +53,9 @@ export default class MessageService extends Service { let key = this.keyFor(realmURL); if (!this.listenerCallbacks.has(key)) { this.listenerCallbacks.set(key, []); - // A world-readable realm's events arrive in a room of its own. - this.realmServer.joinRealmEventRoom(key); + for (let listener of this.firstSubscriptionListeners) { + listener(key); + } } // TODO might want to consider making separate subscription methods so that @@ -69,6 +71,19 @@ export default class MessageService extends Service { }; } + // Calls `listener` with each realm as it gains its first subscriber, starting + // with the realms subscribed to already. A world-readable realm's events + // arrive in a room of their own, which RealmServerService joins this way. + onFirstSubscription(listener: (realm: string) => void): () => void { + this.firstSubscriptionListeners.add(listener); + for (let realm of this.listenerCallbacks.keys()) { + listener(realm); + } + return () => { + this.firstSubscriptionListeners.delete(listener); + }; + } + private removeSubscriptionCallback( realmURL: string, cb: (ev: RealmEventContent) => void, diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index f93c29110cf..ad1226022cc 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -50,6 +50,7 @@ import { import { createServerRequestTimingMiddleware } from './client-telemetry'; import type { ExtendedClient } from './matrix-sdk-loader'; +import type MessageService from './message-service'; import type NetworkService from './network'; import type RealmService from './realm'; import type SessionService from './session'; @@ -121,6 +122,7 @@ export default class RealmServerService extends Service implements RealmServerTokenSource { + @service declare private messageService: MessageService; @service declare private network: NetworkService; @service declare private session: SessionService; @service declare private realm: RealmService; @@ -156,6 +158,9 @@ export default class RealmServerService super(owner); this.session.register(this); this.fetchCatalogRealms(); + this.messageService.onFirstSubscription((realm) => + this.joinRealmEventRoom(realm), + ); } get ready() { @@ -713,7 +718,7 @@ export default class RealmServerService // the user's session room. Joining once is enough: the host stays in the // room, and catches up on what it missed through sync. Realms asked for in // the same turn are asked about together. - joinRealmEventRoom(realmIdentifier: string) { + private joinRealmEventRoom(realmIdentifier: string) { let realmURL = ensureTrailingSlash( resolvedRealmURLHref(this.network.virtualNetwork, realmIdentifier), ); @@ -755,16 +760,10 @@ export default class RealmServerService let { rooms } = (await response.json()) as { rooms?: Record; }; - let roomIds = [...new Set(Object.values(rooms ?? {}))]; - if (roomIds.length === 0) { - return; - } - let { joined_rooms } = await client.getJoinedRooms(); - let joinedRooms = new Set(joined_rooms ?? []); - for (let roomId of roomIds) { - if (!joinedRooms.has(roomId)) { - await client.joinRoom(roomId); - } + // Joining a room the user is already in changes nothing, and the SDK + // answers it locally once sync has the room. + for (let roomId of new Set(Object.values(rooms ?? {}))) { + await client.joinRoom(roomId); } } catch (e) { // Asked about again the next time this runs. diff --git a/packages/host/tests/integration/matrix-service-sliding-sync-test.ts b/packages/host/tests/integration/matrix-service-sliding-sync-test.ts index 1bcfb958e65..bd6485061c9 100644 --- a/packages/host/tests/integration/matrix-service-sliding-sync-test.ts +++ b/packages/host/tests/integration/matrix-service-sliding-sync-test.ts @@ -5,6 +5,7 @@ import { module, test } from 'qunit'; import { baseRealm } from '@cardstack/runtime-common'; import { + APP_BOXEL_REALM_EVENT_ROOM_TYPE, SLIDING_SYNC_AI_ROOM_LIST_NAME, SLIDING_SYNC_AI_ROOM_TIMELINE_LIMIT, } from '@cardstack/runtime-common/matrix-constants'; @@ -63,7 +64,7 @@ module( ); assert.deepEqual( aiListCalls[0]?.list.filters, - { is_dm: false }, + { is_dm: false, not_room_types: [APP_BOXEL_REALM_EVENT_ROOM_TYPE] }, 'AI-room list filters preserved across the bump', ); }); diff --git a/packages/host/tests/integration/message-service-test.ts b/packages/host/tests/integration/message-service-test.ts index d35b650dd73..18b1ade144a 100644 --- a/packages/host/tests/integration/message-service-test.ts +++ b/packages/host/tests/integration/message-service-test.ts @@ -119,17 +119,35 @@ module('Integration | message-service | realm event rooms', function (hooks) { test('subscribing to a world-readable realm joins the room its events go to', async function (assert) { let publicRealmURL = 'http://localhost:4205/public/'; - let roomId = '!public-realm-events:localhost'; + // The realm's own user creates the room; the host is not in it yet. + let roomId = mockMatrixUtils.createAndJoinRoom({ + sender: '@public_realm:localhost', + name: `Realm events: ${publicRealmURL}`, + id: '!public-realm-events:localhost', + }); setRealmEventRoom(publicRealmURL, roomId); + let membership = () => { + try { + return mockMatrixUtils.getRoomState( + roomId, + 'm.room.member', + '@testuser:localhost', + ).membership; + } catch { + return undefined; + } + }; + assert.strictEqual(membership(), undefined, 'the host starts outside it'); let messageService = getService('message-service'); let unsubscribe = messageService.subscribe(publicRealmURL, () => {}); try { - await waitUntil(() => mockMatrixUtils.getRoomIds().includes(roomId), { + await waitUntil(() => membership() === 'join', { timeoutMessage: `the host did not join ${roomId}`, }); - assert.ok( - mockMatrixUtils.getRoomIds().includes(roomId), + assert.strictEqual( + membership(), + 'join', 'the host joined the realm event room', ); } finally { From 6153ab6a2eeb2521b3779193069fe489eb035bee Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 22 Oct 2026 09:00:23 -0400 Subject: [PATCH 3/6] Address review of the shared realm event room - insertRealmEventRoom upserts with ON CONFLICT DO UPDATE ... RETURNING, so a racing insert waits for the winner and returns its room instead of returning no row - /_realm-event-rooms answers 503 (with the rooms it found) when a lookup fails, works through realms four at a time, caps a request at 100 realms and resolves prefix-form identifiers; the host retries failed realms with backoff and joins the rooms a 503 carried - a PATCH /_permissions that changes the '*' grant tells every user the realm names (realms-list-updated), and the host re-asks which room each subscribed realm uses when it hears that event - a realm event room the server user is no longer in is forgotten, so the next host request creates a fresh one - resetState cancels the in-flight join and retry tasks - the mock sliding sync honours room_types / not_room_types, and the host test delivers a realm event through the shared room - comments: the sender identity of a realm's room, and the room window Co-Authored-By: Claude Opus 5.5 --- packages/host/app/services/message-service.ts | 5 ++ packages/host/app/services/realm-server.ts | 53 ++++++++++-- .../host/tests/helpers/mock-matrix/_client.ts | 14 +++- .../helpers/mock-matrix/_server-state.ts | 2 + .../host/tests/helpers/mock-matrix/_utils.ts | 4 + .../tests/integration/message-service-test.ts | 32 ++++++- .../handlers/handle-realm-event-rooms.ts | 83 +++++++++++++------ packages/realm-server/node-realm.ts | 23 +++++ .../realm-server/tests/node-realm-test.ts | 30 +++++++ .../tests/realm-endpoints/permissions-test.ts | 32 +++++++ .../tests/session-room-queries-test.ts | 42 ++++++++++ .../db-queries/session-room-queries.ts | 37 ++++++--- packages/runtime-common/matrix-client.ts | 5 +- packages/runtime-common/matrix-constants.ts | 7 +- packages/runtime-common/realm.ts | 14 +++- 15 files changed, 330 insertions(+), 53 deletions(-) diff --git a/packages/host/app/services/message-service.ts b/packages/host/app/services/message-service.ts index 85e501fb036..0d5bdc6066b 100644 --- a/packages/host/app/services/message-service.ts +++ b/packages/host/app/services/message-service.ts @@ -71,6 +71,11 @@ export default class MessageService extends Service { }; } + // The realms that have at least one subscriber. + subscribedRealms(): string[] { + return [...this.listenerCallbacks.keys()]; + } + // Calls `listener` with each realm as it gains its first subscriber, starting // with the realms subscribed to already. A world-readable realm's events // arrive in a room of their own, which RealmServerService joins this way. diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index ad1226022cc..82610804abe 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -35,6 +35,7 @@ import { type PublicRealmSummary, type RealmServerTokenSource, } from '@cardstack/runtime-common'; +import { REALMS_LIST_UPDATED_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; import { joinDMRoom, RealmAuthClient, @@ -58,6 +59,9 @@ import type { IEvent } from 'matrix-js-sdk'; const { hostsOwnAssets, resolvedBaseRealmURL } = ENV; +const REALM_EVENT_ROOMS_FIRST_RETRY_MS = 2_000; +const REALM_EVENT_ROOMS_MAX_RETRY_MS = 60_000; + export interface RealmServerTokenClaims { user: string; sessionRoom: string; @@ -153,6 +157,9 @@ export default class RealmServerService // asked about this session. private realmEventRoomsPending = new Set(); private realmEventRoomsRequested = new Set(); + // How long to wait before asking again about realms whose rooms could not + // be provided. Doubles on each failure up to a minute; reset on success. + private realmEventRoomsRetryMs = REALM_EVENT_ROOMS_FIRST_RETRY_MS; constructor(owner: Owner) { super(owner); @@ -161,6 +168,12 @@ export default class RealmServerService this.messageService.onFirstSubscription((realm) => this.joinRealmEventRoom(realm), ); + // A change to which realms the user can read can also change whether a + // realm they have open is world-readable, and so which room its events + // arrive in. Ask again about every realm that has a subscriber. + this.subscribeEvent(REALMS_LIST_UPDATED_EVENT_TYPE, async () => { + this.reaskRealmEventRooms(); + }); } get ready() { @@ -189,8 +202,13 @@ export default class RealmServerService 0, this.unreachableRealmServersList.length, ); + // A run in flight belongs to the session that is ending; it must not put + // that session's realms back in line for the next one. + this.joinRealmEventRoomsTask.cancelAll(); + this.retryRealmEventRoomsTask.cancelAll(); this.realmEventRoomsPending.clear(); this.realmEventRoomsRequested.clear(); + this.realmEventRoomsRetryMs = REALM_EVENT_ROOMS_FIRST_RETRY_MS; // Do NOT clear eventSubscribers here: subscriber *wiring* is app-scoped. // Services (e.g. BillingService) call subscribeEvent() once in their // constructor, for the app's lifetime. Logout is an in-app reset, not a @@ -730,6 +748,13 @@ export default class RealmServerService void this.joinRealmEventRoomsTask.perform(); } + private reaskRealmEventRooms() { + this.realmEventRoomsRequested.clear(); + for (let realm of this.messageService.subscribedRealms()) { + this.joinRealmEventRoom(realm); + } + } + private joinRealmEventRoomsTask = task({ keepLatest: true }, async () => { await rawTimeout(0); // Without a matrix client there's no session to join with. Signing in @@ -754,26 +779,40 @@ export default class RealmServerService body: JSON.stringify({ realms }), }, ); - if (!response.ok) { - throw new Error(`${response.status} - ${await response.text()}`); - } - let { rooms } = (await response.json()) as { - rooms?: Record; - }; + // A 503 still carries the rooms the server could provide; the rest are + // asked about again below. + let { rooms } = ( + response.ok || response.status === 503 ? await response.json() : {} + ) as { rooms?: Record }; // Joining a room the user is already in changes nothing, and the SDK // answers it locally once sync has the room. for (let roomId of new Set(Object.values(rooms ?? {}))) { await client.joinRoom(roomId); } + if (!response.ok) { + throw new Error(`${response.status} from _realm-event-rooms`); + } + this.realmEventRoomsRetryMs = REALM_EVENT_ROOMS_FIRST_RETRY_MS; } catch (e) { - // Asked about again the next time this runs. + // Asked about again after a pause, and again on the next subscription. for (let realm of realms) { this.realmEventRoomsPending.add(realm); } console.warn(`Unable to join the realm event rooms of ${realms}`, e); + let retryMs = this.realmEventRoomsRetryMs; + this.realmEventRoomsRetryMs = Math.min( + retryMs * 2, + REALM_EVENT_ROOMS_MAX_RETRY_MS, + ); + void this.retryRealmEventRoomsTask.perform(retryMs); } }); + private retryRealmEventRoomsTask = restartableTask(async (ms: number) => { + await rawTimeout(ms); + void this.joinRealmEventRoomsTask.perform(); + }); + private async ensureJoinedSessionRoom(tokens: { [realmUrl: string]: string; }) { diff --git a/packages/host/tests/helpers/mock-matrix/_client.ts b/packages/host/tests/helpers/mock-matrix/_client.ts index 013e6493e31..fb9477382f3 100644 --- a/packages/host/tests/helpers/mock-matrix/_client.ts +++ b/packages/host/tests/helpers/mock-matrix/_client.ts @@ -1022,14 +1022,26 @@ export class MockClient implements ExtendedClient { for (const [listKey, list] of Object.entries(req.lists || {})) { for (let i = 0; i < list.ranges.length; i++) { let [start, end] = list.ranges[i]; - //currently we only filter rooms using is_dm + // Filters on is_dm, room_types and not_room_types, the filters the + // host's lists use. let dmRooms = (await this.getAccountDataFromServer('m.direct')) ?? {}; + let roomType = (roomId: string): string => + this.serverState.getRoomState(roomId, 'm.room.create', '')?.content + ?.type; let roomsInRange = this.serverState.rooms .filter((r) => list.filters?.is_dm ? dmRooms[this.loggedInAs!]?.includes(r.id) : !dmRooms[this.loggedInAs!]?.includes(r.id), ) + .filter( + (r) => + !list.filters?.room_types || + list.filters.room_types.includes(roomType(r.id)), + ) + .filter( + (r) => !list.filters?.not_room_types?.includes(roomType(r.id)), + ) .slice(start, end + 1); for (let j = 0; j < roomsInRange.length; j++) { diff --git a/packages/host/tests/helpers/mock-matrix/_server-state.ts b/packages/host/tests/helpers/mock-matrix/_server-state.ts index ede825abdbd..557a25df8ba 100644 --- a/packages/host/tests/helpers/mock-matrix/_server-state.ts +++ b/packages/host/tests/helpers/mock-matrix/_server-state.ts @@ -67,6 +67,7 @@ export class ServerState { name?: string, timestamp: number = this.#now(), id?: string, + roomType?: string, ): string { if (document.querySelector('[data-test-throw-room-error]')) { throw new Error('Intentional error thrown'); @@ -92,6 +93,7 @@ export class ServerState { content: { creator: sender, room_version: '0', + ...(roomType ? { type: roomType } : {}), }, }, { origin_server_ts: timestamp, state_key: '' }, diff --git a/packages/host/tests/helpers/mock-matrix/_utils.ts b/packages/host/tests/helpers/mock-matrix/_utils.ts index 1525b449281..e8a73680f14 100644 --- a/packages/host/tests/helpers/mock-matrix/_utils.ts +++ b/packages/host/tests/helpers/mock-matrix/_utils.ts @@ -213,17 +213,21 @@ export class MockUtils { name, id, timestamp, + roomType, }: { sender: string; name: string; id?: string; timestamp?: number; + // The room's `m.room.create` type, which sliding sync lists filter on. + roomType?: string; }) => { let roomId = this.testState.sdk!.serverState.createRoom( sender, name, timestamp, id, + roomType, ); return roomId; }; diff --git a/packages/host/tests/integration/message-service-test.ts b/packages/host/tests/integration/message-service-test.ts index 18b1ade144a..3dc159de241 100644 --- a/packages/host/tests/integration/message-service-test.ts +++ b/packages/host/tests/integration/message-service-test.ts @@ -4,6 +4,10 @@ import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; import { baseRealm } from '@cardstack/runtime-common'; +import { + APP_BOXEL_REALM_EVENT_ROOM_TYPE, + APP_BOXEL_REALM_EVENT_TYPE, +} from '@cardstack/runtime-common/matrix-constants'; import ENV from '@cardstack/host/config/environment'; @@ -124,6 +128,7 @@ module('Integration | message-service | realm event rooms', function (hooks) { sender: '@public_realm:localhost', name: `Realm events: ${publicRealmURL}`, id: '!public-realm-events:localhost', + roomType: APP_BOXEL_REALM_EVENT_ROOM_TYPE, }); setRealmEventRoom(publicRealmURL, roomId); let membership = () => { @@ -140,7 +145,10 @@ module('Integration | message-service | realm event rooms', function (hooks) { assert.strictEqual(membership(), undefined, 'the host starts outside it'); let messageService = getService('message-service'); - let unsubscribe = messageService.subscribe(publicRealmURL, () => {}); + let heard: RealmEventContent[] = []; + let unsubscribe = messageService.subscribe(publicRealmURL, (event) => { + heard.push(event); + }); try { await waitUntil(() => membership() === 'join', { timeoutMessage: `the host did not join ${roomId}`, @@ -150,6 +158,28 @@ module('Integration | message-service | realm event rooms', function (hooks) { 'join', 'the host joined the realm event room', ); + + // An event the realm sends into the room reaches the realm's + // subscribers, which it would not if the room were taken for an AI room. + mockMatrixUtils.simulateRemoteMessage( + roomId, + '@public_realm:localhost', + { + eventName: 'index', + indexType: 'incremental', + invalidations: [`${publicRealmURL}card`], + realmURL: publicRealmURL, + }, + { type: APP_BOXEL_REALM_EVENT_TYPE }, + ); + await waitUntil(() => heard.length > 0, { + timeoutMessage: `no realm event arrived through ${roomId}`, + }); + assert.deepEqual( + heard.map((event) => event.eventName), + ['index'], + "the realm's subscriber heard the event sent into its room", + ); } finally { unsubscribe(); } diff --git a/packages/realm-server/handlers/handle-realm-event-rooms.ts b/packages/realm-server/handlers/handle-realm-event-rooms.ts index 3562e01ce0b..712d493fa0a 100644 --- a/packages/realm-server/handlers/handle-realm-event-rooms.ts +++ b/packages/realm-server/handlers/handle-realm-event-rooms.ts @@ -14,21 +14,30 @@ import { const log = logger('realm:events'); -// More realms than a host has open at once. -const MAX_REALMS_PER_REQUEST = 200; +// A host asks about the realms it has open, batched as they open; this is +// well above that, and bounds what one request can make the server do. +const MAX_REALMS_PER_REQUEST = 100; +// Looking a realm's room up can mount the realm and create its room, so a +// request works through its realms a few at a time. +const REALMS_IN_FLIGHT = 4; // Answers a signed-in host with the rooms that the world-readable realms it // names send their realm events into, so it can join them. A realm's room is // created the first time a host asks for it. A realm that isn't // world-readable, or that this server doesn't serve, is left out of the // answer: its events reach the host through the user's session room, if they -// reach it at all. +// reach it at all. A realm is named by its URL or by a registered prefix. +// +// When a realm's room can't be looked up or created, the answer is a 503 +// carrying the rooms that were found, so the host asks again rather than take +// the realm for one that isn't world-readable. // // Body: `{ realms: string[] }`. Answer: `{ rooms: { [realm]: roomId } }`, // keyed by each realm as the request spelled it. export default function handleRealmEventRooms({ dbAdapter, reconciler, + virtualNetwork, }: CreateRoutesArgs): (ctxt: Koa.Context, next: Koa.Next) => Promise { return async function (ctxt: Koa.Context, _next: Koa.Next) { let request = await fetchRequestFromContext(ctxt); @@ -59,38 +68,58 @@ export default function handleRealmEventRooms({ } let rooms: Record = {}; - await Promise.all( - (realms as string[]).map(async (realm) => { - let realmURL = normalizeRealmURL(realm)?.href; - if (!realmURL) { + let failed = false; + let lookUp = async (realm: string) => { + let identifier = virtualNetwork.isRegisteredPrefix(realm) + ? virtualNetwork.toURL(realm).href + : realm; + let realmURL = normalizeRealmURL(identifier)?.href; + if (!realmURL) { + return; + } + try { + let { worldReadable, roomId } = await fetchRealmEventRoom( + dbAdapter, + realmURL, + ); + if (!worldReadable) { return; } - try { - let { worldReadable, roomId } = await fetchRealmEventRoom( - dbAdapter, - realmURL, - ); - if (!worldReadable) { - return; - } - if (!roomId) { - // Only the realm's own matrix user may send in its room, so the - // realm creates it. - let mounted = await reconciler.lookupOrMount(realmURL); - roomId = (await mounted?.ensureRealmEventRoom()) ?? null; - } - if (roomId) { - rooms[realm] = roomId; - } - } catch (e) { - log.error(`Unable to provide the realm event room of ${realmURL}`, e); + if (!roomId) { + // The realm creates its room, through the matrix client it sends + // its realm events with, so that client's user is the one allowed + // to send in it. + let mounted = await reconciler.lookupOrMount(realmURL); + roomId = (await mounted?.ensureRealmEventRoom()) ?? null; } - }), + if (roomId) { + rooms[realm] = roomId; + } + } catch (e) { + failed = true; + log.error(`Unable to provide the realm event room of ${realmURL}`, e); + } + }; + let queue = [...new Set(realms as string[])]; + await Promise.all( + Array.from( + { length: Math.min(REALMS_IN_FLIGHT, queue.length) }, + async () => { + for ( + let realm = queue.shift(); + realm !== undefined; + realm = queue.shift() + ) { + await lookUp(realm); + } + }, + ), ); await setContextResponse( ctxt, new Response(JSON.stringify({ rooms }), { + status: failed ? 503 : 200, headers: { 'content-type': SupportedMimeType.JSON }, }), ); diff --git a/packages/realm-server/node-realm.ts b/packages/realm-server/node-realm.ts index 18a4aee4409..c483beb952d 100644 --- a/packages/realm-server/node-realm.ts +++ b/packages/realm-server/node-realm.ts @@ -12,6 +12,7 @@ import { unixTime, type ResponseWithNodeStream, type TokenClaims, + clearRealmEventRoom, clearSessionRoom, fetchRealmEventRoom, fetchRealmSessionRooms, @@ -531,6 +532,28 @@ export class NodeAdapter implements RealmAdapter { ); }, onFailure: async (e: unknown) => { + // A stored room the server user is no longer in, as after a + // homeserver reset, is forgotten: the next host that asks for the + // realm's room gets a new one. + if (isRealmServerNotInRoomError(e, roomId)) { + try { + let cleared = await clearRealmEventRoom( + dbAdapter, + realmUrl, + roomId, + ); + realmEventsLog.warn( + `Forgetting realm event room ${roomId} of ${realmUrl}; realm server is no longer in the room`, + { cleared }, + ); + return; + } catch (cleanupError) { + realmEventsLog.error( + `Failed to forget realm event room ${roomId} of ${realmUrl}`, + cleanupError, + ); + } + } realmEventsLog.error( `Unable to send event in realm event room ${roomId} for ${realmUrl}: ${ e instanceof Error ? e.message : String(e) diff --git a/packages/realm-server/tests/node-realm-test.ts b/packages/realm-server/tests/node-realm-test.ts index 019bbc5e794..44020f656d9 100644 --- a/packages/realm-server/tests/node-realm-test.ts +++ b/packages/realm-server/tests/node-realm-test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'os'; import fsExtra from 'fs-extra'; import type { PgAdapter } from '@cardstack/postgres'; import { + fetchRealmEventRoom, fetchSessionRoom, insertPermissions, insertRealmEventRoom, @@ -314,6 +315,35 @@ module(basename(import.meta.filename), function (hooks) { assert.deepEqual(sentTo, [], 'no room was sent to'); }); + test("forgets a world-readable realm's event room the realm server is no longer in", async function (assert) { + await insertPermissions(dbAdapter, realmURL, { '*': ['read'] }); + await insertRealmEventRoom(dbAdapter, realmURL.href, '!events:localhost'); + + let matrixClient = { + login: async () => undefined, + getUserId: () => '@realm_server:localhost', + isLoggedIn: () => true, + nextTxnId: () => 'txn-1', + sendEvent: async () => { + throw new Error( + `Unable to send room event 'app.boxel.realm-event' to room !events:localhost: status 403 - {"errcode":"M_FORBIDDEN","error":"User @realm_server:localhost not in room !events:localhost"}`, + ); + }, + } as unknown as MatrixClient; + + await new NodeAdapter( + '/tmp', + false, + new RealmEventDispatcher(), + ).broadcastRealmEvent(makeEvent(), realmURL.href, matrixClient, dbAdapter); + + assert.deepEqual( + await fetchRealmEventRoom(dbAdapter, realmURL.href), + { worldReadable: true, roomId: null }, + 'the next host to ask gets a new room', + ); + }); + test("sends a realm's event to session rooms once it is no longer world-readable", async function (assert) { await insertSessionForAlice(); await insertRealmEventRoom(dbAdapter, realmURL.href, '!events:localhost'); diff --git a/packages/realm-server/tests/realm-endpoints/permissions-test.ts b/packages/realm-server/tests/realm-endpoints/permissions-test.ts index 3c8d4f7d19b..ff97d1857ec 100644 --- a/packages/realm-server/tests/realm-endpoints/permissions-test.ts +++ b/packages/realm-server/tests/realm-endpoints/permissions-test.ts @@ -170,6 +170,38 @@ module(`realm-endpoints/${basename(import.meta.filename)}`, function () { ); }); + test("PATCH /_permissions of the '*' grant pushes realms-list-updated to every user the realm names", async function (assert) { + // Making a realm world-readable moves its events into a room of its + // own, so a host that has it open must ask again where to listen. + await insertPermissions(dbAdapter, testRealmURL, { + [granteeUserId]: ['read'], + }); + let response = await request + .patch('/_permissions') + .set('Accept', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'mary', [ + 'read', + 'write', + 'realm-owner', + ])}`, + ) + .send({ + data: { + id: testRealmHref, + type: 'permissions', + attributes: { permissions: { '*': ['read'] } }, + }, + }); + assert.strictEqual(response.status, 200, 'the grant succeeds'); + + assert.ok( + await receivedRealmsListUpdated(matrixClient, sessionRoom), + 'a realms-list-updated event reached the session room of a user the realm already named', + ); + }); + test('PATCH /_permissions still succeeds when the grantee has no session room', async function (assert) { // A user who never established a session room (e.g. a fresh // Synapse-registered account) has nowhere to deliver to. The push is a diff --git a/packages/realm-server/tests/session-room-queries-test.ts b/packages/realm-server/tests/session-room-queries-test.ts index ecf9e6ddd6a..f7303c2f441 100644 --- a/packages/realm-server/tests/session-room-queries-test.ts +++ b/packages/realm-server/tests/session-room-queries-test.ts @@ -8,6 +8,7 @@ import { fetchRealmEventRoom, fetchSessionRoom, fetchRealmSessionRooms, + clearRealmEventRoom, insertRealmEventRoom, upsertSessionRoom, } from '@cardstack/runtime-common/db-queries/session-room-queries'; @@ -372,6 +373,47 @@ module(basename(import.meta.filename), function () { 'the room stored first is the one returned', ); }); + + test('rooms stored for a realm at once all come back as the one kept', async function (assert) { + let stored = await Promise.all( + ['!a:localhost', '!b:localhost', '!c:localhost', '!d:localhost'].map( + (roomId) => insertRealmEventRoom(dbAdapter, realmURL.href, roomId), + ), + ); + let { roomId } = await fetchRealmEventRoom(dbAdapter, realmURL.href); + assert.ok(roomId, 'a room is stored'); + assert.deepEqual( + stored, + stored.map(() => roomId), + 'every caller gets the stored room back', + ); + }); + + test('forgets the stored room only when it is the one named', async function (assert) { + await insertPermissions(dbAdapter, realmURL, { '*': ['read'] }); + await insertRealmEventRoom(dbAdapter, realmURL.href, '!events:localhost'); + + assert.false( + await clearRealmEventRoom(dbAdapter, realmURL.href, '!other:localhost'), + 'another room is not forgotten', + ); + assert.strictEqual( + (await fetchRealmEventRoom(dbAdapter, realmURL.href)).roomId, + '!events:localhost', + ); + assert.true( + await clearRealmEventRoom( + dbAdapter, + realmURL.href, + '!events:localhost', + ), + 'the stored room is forgotten', + ); + assert.strictEqual( + (await fetchRealmEventRoom(dbAdapter, realmURL.href)).roomId, + null, + ); + }); }); module('clearSessionRoom', function (hooks) { diff --git a/packages/runtime-common/db-queries/session-room-queries.ts b/packages/runtime-common/db-queries/session-room-queries.ts index 80f59c2f083..5127486188a 100644 --- a/packages/runtime-common/db-queries/session-room-queries.ts +++ b/packages/runtime-common/db-queries/session-room-queries.ts @@ -131,7 +131,8 @@ export async function fetchRealmEventRoom( * Stores the given room as the realm's event room, unless the realm already * has one, and returns the room that is stored. When two processes create a * room for the same realm at once, the first stored wins and the other's room - * goes unused. + * goes unused. A conflicting insert waits for the row it conflicts with and + * reads that row's room, so every caller gets the winner back. */ export async function insertRealmEventRoom( dbAdapter: DBAdapter, @@ -139,19 +140,33 @@ export async function insertRealmEventRoom( roomId: string, ): Promise { let rows = await query(dbAdapter, [ - 'WITH inserted AS (', - ' INSERT INTO realm_event_rooms (realm_url, room_id) VALUES (', + 'INSERT INTO realm_event_rooms (realm_url, room_id) VALUES (', param(realmURL), ',', param(roomId), - ' ) ON CONFLICT (realm_url) DO NOTHING', - ' RETURNING room_id', - ')', - 'SELECT room_id FROM inserted', - 'UNION ALL', - 'SELECT room_id FROM realm_event_rooms WHERE realm_url =', - param(realmURL), - 'LIMIT 1', + ') ON CONFLICT (realm_url) DO UPDATE', + 'SET room_id = realm_event_rooms.room_id', + 'RETURNING room_id', ]); return rows[0].room_id as string; } + +/** + * Forgets the realm's event room when it is the given room, so the next host + * that asks for the realm's room gets a new one. For a stored room the realm + * server can no longer send in, such as after its homeserver was reset. + */ +export async function clearRealmEventRoom( + dbAdapter: DBAdapter, + realmURL: string, + roomId: string, +): Promise { + let rows = await query(dbAdapter, [ + 'DELETE FROM realm_event_rooms WHERE realm_url =', + param(realmURL), + 'AND room_id =', + param(roomId), + 'RETURNING realm_url', + ]); + return rows.length > 0; +} diff --git a/packages/runtime-common/matrix-client.ts b/packages/runtime-common/matrix-client.ts index c06926ea44b..edf9523a18f 100644 --- a/packages/runtime-common/matrix-client.ts +++ b/packages/runtime-common/matrix-client.ts @@ -316,8 +316,9 @@ export class MatrixClient { // The room a world-readable realm sends its realm events into. Any user on // the homeserver may join it, and a member sees only what is sent after - // they join. Only this client's user, the realm's, may send in it or change - // its state. + // they join. Only this client's user, the one the realm sends its realm + // events as, may send in it or change its state. A realm server's realms + // share that user, so a member can't forge a realm event either way. async createRealmEventRoom(realmURL: string): Promise { let response = await this.request('_matrix/client/v3/createRoom', 'POST', { body: JSON.stringify({ diff --git a/packages/runtime-common/matrix-constants.ts b/packages/runtime-common/matrix-constants.ts index f0beaa2b23c..2bdca686bfe 100644 --- a/packages/runtime-common/matrix-constants.ts +++ b/packages/runtime-common/matrix-constants.ts @@ -307,9 +307,10 @@ export const INITIAL_SLIDING_SYNC_LIST_TIMELINE_LIMIT = 1; export const SLIDING_SYNC_AI_BOT_ROOM_LIST_RANGE: [number, number] = [0, 99]; export const SLIDING_SYNC_AI_ROOM_TIMELINE_LIMIT = 20; export const SLIDING_SYNC_AUTH_ROOM_TIMELINE_LIMIT = 20; -// Sliding sync reports changes only for rooms inside a list's range, so the -// realm-event-room window is wide enough for every world-readable realm a host -// has open at once. +// Sliding sync orders a list by recent activity and reports changes only for +// rooms inside its range. A host stays in every realm event room it has +// joined, so it can hold more rooms than this window, but a room moves back +// into the window as soon as it gets an event. export const SLIDING_SYNC_REALM_EVENT_ROOM_LIST_RANGE: [number, number] = [ 0, 99, ]; diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 5e71c085329..3b17dae1d52 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -16411,7 +16411,19 @@ export class Realm { // session re-derives it from `_realm-auth` (which reads the permissions // written just above) without a reload. Nothing else notifies a grantee: // the index_updated broadcast above is server-to-server only. - await this.notifyRealmsListUpdated(Object.keys(patch)); + // + // A change to the `'*'` grant moves where the realm's events go: a + // world-readable realm sends them into a room of its own, any other realm + // into its grantees' session rooms. So every user the realm grants by + // name hears of it too, and their hosts ask again which room to listen in. + let affected = new Set(Object.keys(patch)); + if (affected.has('*')) { + for (let user of Object.keys(currentPermissions)) { + affected.add(user); + } + } + affected.delete('*'); + await this.notifyRealmsListUpdated([...affected]); return await this.getRealmPermissions(request, requestContext); } From 3c350a8cdbeb1c787c9ff600d62cb8b403a63867 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 22 Oct 2026 09:08:57 -0400 Subject: [PATCH 4/6] Let a host that is still joining a realm event room see what is sent meanwhile The room's history is visible to members ('shared' instead of 'joined'), so an event sent between the room's creation and a host's join reaches that host; the host already ignores events from before it started. The room lookup's single-server assumption is marked with a TODO citing ADR 004. Co-Authored-By: Claude Opus 5.5 --- packages/host/app/services/realm-server.ts | 6 ++++++ packages/runtime-common/matrix-client.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index 82610804abe..12de3603e55 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -767,6 +767,12 @@ export default class RealmServerService this.realmEventRoomsPending.clear(); try { await this.login(); + // TODO(ADR 004 §1.7–1.9): every realm is asked about at the home realm + // server, which answers only for its own realms. That holds while the + // host serves realms from its home server alone (see + // `assertOwnRealmServer`). Once realms come from several servers, ask + // each server about its own realms with that server's session, and + // join the rooms through that server's homeserver. let response = await this.network.fetch( `${this.url.href}_realm-event-rooms`, { diff --git a/packages/runtime-common/matrix-client.ts b/packages/runtime-common/matrix-client.ts index edf9523a18f..d21622aafe3 100644 --- a/packages/runtime-common/matrix-client.ts +++ b/packages/runtime-common/matrix-client.ts @@ -315,8 +315,11 @@ export class MatrixClient { } // The room a world-readable realm sends its realm events into. Any user on - // the homeserver may join it, and a member sees only what is sent after - // they join. Only this client's user, the one the realm sends its realm + // the homeserver may join it. A member can read the room's history, so a + // host that is still joining when an event is sent still receives it; the + // host ignores events from before it started, and the retention policy + // bounds how far back the history goes. The events are about a realm anyone + // can read. Only this client's user, the one the realm sends its realm // events as, may send in it or change its state. A realm server's realms // share that user, so a member can't forge a realm event either way. async createRealmEventRoom(realmURL: string): Promise { @@ -330,7 +333,7 @@ export class MatrixClient { { type: 'm.room.history_visibility', state_key: '', - content: { history_visibility: 'joined' }, + content: { history_visibility: 'shared' }, }, { type: 'm.room.retention', From 37768d3525de329d19c9bca74b734085a034220a Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 22 Oct 2026 09:37:19 -0400 Subject: [PATCH 5/6] Report a newly joined room in the sliding sync list that admits its type The host test mock reported every newly joined room in the AI room list, so a realm event room became an AI room and its events never reached the realm's subscribers. It now picks the list whose room_types admit the room, and leaves out of the AI list a room that list excludes. Co-Authored-By: Claude Opus 5.5 --- .../helpers/mock-matrix/_sliding-sync.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts b/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts index 65790665820..666a40103e9 100644 --- a/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts +++ b/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts @@ -98,11 +98,36 @@ export class MockSlidingSync extends SlidingSync { return; } + // The room is reported in the list whose room-type filters admit it, as + // the homeserver would: a typed room goes to the list that asks for its + // type, and to the AI room list only when that list doesn't exclude it. + let roomType = (() => { + try { + return serverState?.getRoomState?.(roomId, 'm.room.create', '')?.content + ?.type as string | undefined; + } catch { + return undefined; + } + })(); + let listKey = + (roomType && + Object.keys(this._lists).find((key) => + this._lists[key]?.filters?.room_types?.includes(roomType), + )) || + 'ai-room'; + if ( + listKey === 'ai-room' && + roomType && + this._lists['ai-room']?.filters?.not_room_types?.includes(roomType) + ) { + return; + } + // Create a mock sliding sync response that includes the new room let mockResponse = { pos: String(Date.now()), lists: { - ['ai-room']: { + [listKey]: { count: 1, ops: [ { From 36863245c920b8f34186759342f6222fe84c4ef7 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Thu, 22 Oct 2026 10:41:55 -0400 Subject: [PATCH 6/6] Read the room type from the mock's create-event content getRoomState answers with the state event's content, so the room type is its `type`, not `content.type`. Without it every typed room still went to the AI room list. Co-Authored-By: Claude Opus 5.5 --- packages/host/tests/helpers/mock-matrix/_client.ts | 4 ++-- packages/host/tests/helpers/mock-matrix/_sliding-sync.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/host/tests/helpers/mock-matrix/_client.ts b/packages/host/tests/helpers/mock-matrix/_client.ts index fb9477382f3..e1dd7f002ac 100644 --- a/packages/host/tests/helpers/mock-matrix/_client.ts +++ b/packages/host/tests/helpers/mock-matrix/_client.ts @@ -1025,9 +1025,9 @@ export class MockClient implements ExtendedClient { // Filters on is_dm, room_types and not_room_types, the filters the // host's lists use. let dmRooms = (await this.getAccountDataFromServer('m.direct')) ?? {}; + // getRoomState answers with the state event's content. let roomType = (roomId: string): string => - this.serverState.getRoomState(roomId, 'm.room.create', '')?.content - ?.type; + this.serverState.getRoomState(roomId, 'm.room.create', '')?.type; let roomsInRange = this.serverState.rooms .filter((r) => list.filters?.is_dm diff --git a/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts b/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts index 666a40103e9..5a9465cd2bc 100644 --- a/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts +++ b/packages/host/tests/helpers/mock-matrix/_sliding-sync.ts @@ -103,7 +103,8 @@ export class MockSlidingSync extends SlidingSync { // type, and to the AI room list only when that list doesn't exclude it. let roomType = (() => { try { - return serverState?.getRoomState?.(roomId, 'm.room.create', '')?.content + // getRoomState answers with the state event's content. + return serverState?.getRoomState?.(roomId, 'm.room.create', '') ?.type as string | undefined; } catch { return undefined;