From 19f2dd28621ce950806be77dcab0906a7b4fc97b Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 23 Jun 2026 12:29:39 -0400 Subject: [PATCH 1/6] CS-11659: lazy migration on host boot seeds app.boxel.realm-servers When `app.boxel.realm-servers` is absent and boot falls back to the legacy `app.boxel.realms` list, derive the distinct realm-server URLs backing the user's existing realms and persist them to `app.boxel.realm-servers` so future boots take the authoritative trusted-servers assembly path. A realm's server is the origin of its URL, cross-checked against the JWT `realmServerURL` claim when a session token is present. The legacy `app.boxel.realms` key is retained for rollback safety. The migration fires at most once per account and is a no-op for already-migrated accounts. Co-Authored-By: Claude Opus 4.8 --- packages/host/app/services/matrix-service.ts | 28 +++++ packages/host/app/services/realm-server.ts | 54 +++++---- .../host/tests/helpers/mock-matrix/_utils.ts | 8 ++ .../matrix-service-boot-assembly-test.ts | 104 ++++++++++++++++++ .../matrix-service-realm-servers-test.ts | 40 +++++++ 5 files changed, 215 insertions(+), 19 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index ad86cf41a9c..f5e5dd5c312 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -923,6 +923,34 @@ export default class MatrixService extends Service { APP_BOXEL_REALMS_EVENT_TYPE, )) as { realms: string[] } | null; userRealmURLs = legacyRealmsData?.realms ?? []; + + // Lazy migration: this account predates `app.boxel.realm-servers` + // (the key was absent or empty, so boot fell back to the legacy + // realm list above). Seed the new key from the trusted realm-server + // origins backing the user's existing realm URLs so future boots + // take the authoritative trusted-servers assembly path. The legacy + // `app.boxel.realms` key is intentionally retained for rollback + // safety during the transition window. Once written the new key is + // non-empty, so subsequent boots skip this branch — the migration + // fires at most once per account, and is a no-op for an account + // with no realms (nothing to derive). Best-effort: a derivation + // failure must not break boot. + if (userRealmURLs.length > 0) { + try { + let derivedRealmServers = + this.realmServer.deriveRealmServerURLsForRealms(userRealmURLs); + if (derivedRealmServers.length > 0) { + if (isTesting()) + console.warn('[start-phase] migrateRealmServersAccountData'); + await this.setRealmServersInAccountData(derivedRealmServers); + } + } catch (err) { + console.error( + 'Failed to migrate legacy realms to app.boxel.realm-servers account data', + err, + ); + } + } } let noRealmsLoggedIn = Array.from(this.realm.realms.entries()).every( diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index e1d818cc092..de7ebe2f7a9 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -332,15 +332,7 @@ export default class RealmServerService extends Service { let testRealmOrigin = isTesting() ? new URL(testRealmURL).origin : undefined; - let sessionTokens: Record = {}; - let sessionStr = - window.localStorage.getItem(SessionLocalStorageKey) ?? '{}'; - - try { - sessionTokens = JSON.parse(sessionStr) as Record; - } catch { - sessionTokens = {}; - } + let sessionTokens = this.readSessionTokens(); let realmServerURLs = new Set(); @@ -372,6 +364,39 @@ export default class RealmServerService extends Service { return [...realmServerURLs]; } + // Derives the distinct realm-server URLs backing the given realm URLs. + // Used by the one-time host-boot migration that seeds + // `app.boxel.realm-servers` from the legacy `app.boxel.realms` list. A + // realm's server is the origin of its URL; when a session token for that + // realm is present its `realmServerURL` claim is authoritative and taken + // as a cross-check over the bare origin. Results run through the same + // normalization as the rest of the service, so the distinct origins that + // back a single server (e.g. the test-realm origin and the base realm + // origin in tests) collapse to that one server. + deriveRealmServerURLsForRealms(realms: string[]): string[] { + let sessionTokens = this.readSessionTokens(); + let realmServerURLs = new Set(); + for (let realmURL of realms) { + let normalizedRealmURL = ensureTrailingSlash(realmURL); + let token = sessionTokens[normalizedRealmURL] ?? sessionTokens[realmURL]; + let claims = token ? realmClaimsFromRawToken(token) : undefined; + let realmServerURL = + claims?.realmServerURL ?? new URL(normalizedRealmURL).origin; + realmServerURLs.add(this.normalizeRealmServerURL(realmServerURL)); + } + return [...realmServerURLs]; + } + + private readSessionTokens(): Record { + let sessionStr = + window.localStorage.getItem(SessionLocalStorageKey) ?? '{}'; + try { + return JSON.parse(sessionStr) as Record; + } catch { + return {}; + } + } + private normalizeRealmServerURL(url: string): string { let normalizedURL = ensureTrailingSlash(url); if (isTesting()) { @@ -1223,16 +1248,7 @@ export default class RealmServerService extends Service { } private getRealmTokenForRealms(realms: string[]): string | undefined { - let sessionTokens: Record = {}; - let sessionStr = window.localStorage.getItem(SessionLocalStorageKey); - if (!sessionStr) { - return undefined; - } - try { - sessionTokens = JSON.parse(sessionStr) as Record; - } catch { - return undefined; - } + let sessionTokens = this.readSessionTokens(); for (let realmURL of realms) { let normalizedRealmURL = ensureTrailingSlash(realmURL); let token = sessionTokens[normalizedRealmURL] ?? sessionTokens[realmURL]; diff --git a/packages/host/tests/helpers/mock-matrix/_utils.ts b/packages/host/tests/helpers/mock-matrix/_utils.ts index 7f489d73a73..68210e9d2fb 100644 --- a/packages/host/tests/helpers/mock-matrix/_utils.ts +++ b/packages/host/tests/helpers/mock-matrix/_utils.ts @@ -55,6 +55,14 @@ export class MockUtils { return this.testState.opts?.systemCardAccountData; }; + getActiveRealms = () => { + return this.testState.opts?.activeRealms ?? []; + }; + + getActiveRealmServers = () => { + return this.testState.opts?.activeRealmServers ?? []; + }; + getRealmEventMessagesSince = (roomId: string, since: number) => { return this.testState .sdk!.serverState.getRoomEvents(roomId) diff --git a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts index 40af38cd297..34b700e125b 100644 --- a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -22,6 +22,12 @@ import { setupMockMatrix } from '../helpers/mock-matrix'; import { setupRenderingTest } from '../helpers/setup'; const testRealmServerURL = ensureTrailingSlash(ENV.realmServerURL); +// The realm-server service normalizes the test-realm origin onto the base +// realm origin (see `normalizeRealmServerURL`), so a realm rooted at the +// test-realm origin resolves to this canonical server identity. +const normalizedTrustedServerURL = ensureTrailingSlash( + new URL(ENV.resolvedBaseRealmURL).origin, +); // Boot assembles the available-realms list from the user's trusted // realm-servers (`app.boxel.realm-servers`) by asking each via @@ -182,3 +188,101 @@ module( }); }, ); + +// CS-11659: lazy migration on host boot. A user who predates +// `app.boxel.realm-servers` (only `app.boxel.realms` set) has the new key +// seeded on next boot from the realm-server origins backing their existing +// realm URLs. The legacy key is retained for rollback safety. +module( + 'Integration | matrix-service | lazy migration seeds realm-servers', + function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + // Only the legacy `app.boxel.realms` key is set (no activeRealmServers), + // matching a not-yet-migrated account. + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + }); + + hooks.beforeEach(async function (this: RenderingTestContext) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + startMatrix: false, + }); + let realmServer = getService('realm-server') as RealmServerService; + await realmServer.setAvailableRealmIdentifiers([]); + let matrixService = getService('matrix-service') as MatrixService; + await matrixService.ready; + await matrixService.start(); + }); + + test('boot writes `app.boxel.realm-servers` derived from the realm origins', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + let realmServers = await matrixService.getRealmServersFromAccountData(); + assert.deepEqual( + realmServers, + [normalizedTrustedServerURL], + 'the trusted realm-server backing testRealmURL is persisted', + ); + }); + + test('boot retains the legacy `app.boxel.realms` key', async function (assert) { + assert.deepEqual( + mockMatrixUtils.getActiveRealms(), + [testRealmURL], + 'app.boxel.realms is left intact for rollback safety', + ); + }); + + test('boot still assembles the available realms', async function (assert) { + let realmServer = getService('realm-server') as RealmServerService; + assert.ok( + realmServer.availableRealmIdentifiers.includes(ri(testRealmURL)), + 'testRealmURL is present in availableRealmIdentifiers', + ); + }); + }, +); + +module( + 'Integration | matrix-service | already-migrated account is untouched', + function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + // `app.boxel.realm-servers` is already populated, so boot takes the + // trusted-servers path and the migration must not run. + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [testRealmURL], + activeRealmServers: [testRealmServerURL], + }); + + hooks.beforeEach(async function (this: RenderingTestContext) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + startMatrix: false, + }); + let realmServer = getService('realm-server') as RealmServerService; + await realmServer.setAvailableRealmIdentifiers([]); + let matrixService = getService('matrix-service') as MatrixService; + await matrixService.ready; + await matrixService.start(); + }); + + test('boot leaves `app.boxel.realm-servers` unchanged', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + assert.deepEqual( + await matrixService.getRealmServersFromAccountData(), + [testRealmServerURL], + 'the existing realm-servers list is neither rewritten nor duplicated', + ); + }); + }, +); diff --git a/packages/host/tests/integration/matrix-service-realm-servers-test.ts b/packages/host/tests/integration/matrix-service-realm-servers-test.ts index 4515d306550..a66cfb5a224 100644 --- a/packages/host/tests/integration/matrix-service-realm-servers-test.ts +++ b/packages/host/tests/integration/matrix-service-realm-servers-test.ts @@ -1,11 +1,14 @@ import type { RenderingTestContext } from '@ember/test-helpers'; import { getService } from '@universal-ember/test-support'; +import window from 'ember-window-mock'; import { module, test } from 'qunit'; import { baseRealm } from '@cardstack/runtime-common'; import type MatrixService from '@cardstack/host/services/matrix-service'; +import type RealmServerService from '@cardstack/host/services/realm-server'; +import { SessionLocalStorageKey } from '@cardstack/host/utils/local-storage-keys'; import { testRealmURL, @@ -105,5 +108,42 @@ module( 'removing a non-existent server leaves the list unchanged', ); }); + + // CS-11659: the lazy boot migration derives the realm-server URLs to seed + // into `app.boxel.realm-servers`. These exercise the derivation directly + // with non-test origins so `normalizeRealmServerURL` is a no-op and the + // origin-vs-claim resolution is observable. + test('deriveRealmServerURLsForRealms uses the realm URL origin when no token is present', async function (assert) { + let realmServer = getService('realm-server') as RealmServerService; + window.localStorage.removeItem(SessionLocalStorageKey); + + assert.deepEqual( + realmServer.deriveRealmServerURLsForRealms([ + 'https://content.example/my-realm/', + 'https://content.example/another-realm/', + ]), + ['https://content.example/'], + 'distinct realms sharing an origin collapse to a single server', + ); + }); + + test('deriveRealmServerURLsForRealms prefers the JWT realmServerURL claim over the origin', async function (assert) { + let realmServer = getService('realm-server') as RealmServerService; + let realmURL = 'https://content.example/my-realm/'; + // A realm whose content is served from one origin but whose JWT names a + // different realm-server origin — the claim is authoritative. + let claim = { realmServerURL: 'https://api.example/' }; + let token = `header.${btoa(JSON.stringify(claim))}.signature`; + window.localStorage.setItem( + SessionLocalStorageKey, + JSON.stringify({ [realmURL]: token }), + ); + + assert.deepEqual( + realmServer.deriveRealmServerURLsForRealms([realmURL]), + ['https://api.example/'], + 'the realmServerURL claim cross-checks and overrides the bare origin', + ); + }); }, ); From cbb7a74a04f6057a760a58c5fda374ae5a3302a6 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 23 Jun 2026 12:56:03 -0400 Subject: [PATCH 2/6] Guard boot migration self-write from re-triggering trusted-servers assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy migration writes app.boxel.realm-servers during boot, and that write echoes back through the AccountData listener mid-boot — re-running trusted-servers assembly (extra _realm-auth fetches, a racing setAvailableRealmIdentifiers) and breaking unrelated host tests. Set a guard flag around the self-write so the listener ignores it; this session is already assembled from the equivalent legacy realm list, and the new key takes effect on the next boot. Also reset realm-servers account data to empty in the raw-helper test's beforeEach, since the migration now seeds it during the autostart boot. Co-Authored-By: Claude Opus 4.8 --- packages/host/app/services/matrix-service.ts | 24 ++++++++++++++++++- .../matrix-service-realm-servers-test.ts | 9 +++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index f5e5dd5c312..b5f9c80633e 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -182,6 +182,12 @@ export default class MatrixService extends Service { // gains content at runtime. Login-related side effects (`loginToRealms`, // `loadMoreAuthRooms`) still run regardless. private trustedRealmServersAuthoritative = false; + // Set only while the boot-time lazy migration writes `app.boxel.realm-servers` + // itself, so the AccountData listener ignores that self-write rather than + // re-running trusted-servers assembly mid-boot. The migration has already + // assembled this session from the equivalent legacy realm list; the new key + // only needs to take effect on the next boot. + private migratingRealmServersAccountData = false; @tracked private _currentRoomId: string | undefined; @tracked private timelineLoadingState: Map = new TrackedMap(); @@ -421,6 +427,13 @@ export default class MatrixService extends Service { break; } case APP_BOXEL_REALM_SERVERS_EVENT_TYPE: { + // The boot-time lazy migration's own write echoes back here; it + // has already assembled this session from the equivalent legacy + // realm list, so ignore the self-write rather than re-running + // assembly mid-boot. + if (this.migratingRealmServersAccountData) { + break; + } let realmServers = e.event.content.realmServers as string[]; this.trustedRealmServersAuthoritative = realmServers.length > 0; if (this.trustedRealmServersAuthoritative) { @@ -942,7 +955,16 @@ export default class MatrixService extends Service { if (derivedRealmServers.length > 0) { if (isTesting()) console.warn('[start-phase] migrateRealmServersAccountData'); - await this.setRealmServersInAccountData(derivedRealmServers); + // Guard so this self-write doesn't re-trigger trusted-servers + // assembly via the AccountData listener while boot is still + // running — this session is already assembled from the legacy + // list; the new key takes effect on the next boot. + this.migratingRealmServersAccountData = true; + try { + await this.setRealmServersInAccountData(derivedRealmServers); + } finally { + this.migratingRealmServersAccountData = false; + } } } catch (err) { console.error( diff --git a/packages/host/tests/integration/matrix-service-realm-servers-test.ts b/packages/host/tests/integration/matrix-service-realm-servers-test.ts index a66cfb5a224..e838d8241c5 100644 --- a/packages/host/tests/integration/matrix-service-realm-servers-test.ts +++ b/packages/host/tests/integration/matrix-service-realm-servers-test.ts @@ -45,15 +45,20 @@ module( mockMatrixUtils, contents: {}, }); + // Boot's lazy migration (CS-11659) seeds `app.boxel.realm-servers` from + // the active realms, so reset to a known-empty starting point — these + // tests exercise the raw read/write helpers, not the migration. + let matrixService = getService('matrix-service') as MatrixService; + await matrixService.setRealmServersInAccountData([]); }); - test('get returns empty when no event has been written', async function (assert) { + test('get returns empty when the realm-servers list is empty', async function (assert) { let matrixService = getService('matrix-service') as MatrixService; let servers = await matrixService.getRealmServersFromAccountData(); assert.deepEqual( servers, [], - 'returns an empty array when the event is absent', + 'returns an empty array when the event has no servers', ); }); From efbaeb7336de19e49abefb03283f3a1eafa2ff64 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 23 Jun 2026 13:44:54 -0400 Subject: [PATCH 3/6] Keep a legacy-booted session on the legacy realm path across re-boots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the lazy migration writes app.boxel.realm-servers, a later start() on the same MatrixService instance (e.g. a test that re-boots to pick up a newly-added realm) read the freshly-written key and switched to the trusted-servers path, re-deriving the realm list from _realm-auth and dropping realms the stub doesn't advertise — breaking spec-preview and card-copy tests. Make the legacy path sticky for the instance's lifetime via bootedFromLegacyRealmsList: once a boot assembles from app.boxel.realms, subsequent boots stay legacy and the migration write is skipped (gated on trustedServers being genuinely empty). The migration still persists the new key, so the next fresh session takes the trusted path. Reset on resetState() so a logout/login re-evaluates. Co-Authored-By: Claude Opus 4.8 --- packages/host/app/services/matrix-service.ts | 34 ++++++++++++++----- .../matrix-service-boot-assembly-test.ts | 22 ++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index b5f9c80633e..92d30e9c60d 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -182,6 +182,12 @@ export default class MatrixService extends Service { // gains content at runtime. Login-related side effects (`loginToRealms`, // `loadMoreAuthRooms`) still run regardless. private trustedRealmServersAuthoritative = false; + // Sticky for the lifetime of this instance once a boot assembles from the + // legacy `app.boxel.realms` list. Keeps later start() calls on the legacy + // path even after the lazy migration writes `app.boxel.realm-servers`, so + // the migration only takes effect on the next fresh session. Reset by + // resetState() so a logout/login re-evaluates against the persisted key. + private bootedFromLegacyRealmsList = false; // Set only while the boot-time lazy migration writes `app.boxel.realm-servers` // itself, so the AccountData listener ignores that self-write rather than // re-running trusted-servers assembly mid-boot. The migration has already @@ -916,13 +922,23 @@ export default class MatrixService extends Service { // the lazy migration that populates `app.boxel.realm-servers` has // run on all active accounts. let trustedServers = realmServersData?.realmServers ?? []; + // A session that first assembled from the legacy `app.boxel.realms` + // list stays on the legacy path for the lifetime of this + // MatrixService instance. The lazy migration below persists + // `app.boxel.realm-servers` for the next fresh session; switching + // this same instance to the trusted path on a later start() (e.g. a + // test that re-boots to pick up a newly-added realm) would re-derive + // the realm list from `_realm-auth` for no benefit and drop realms + // that the trusted servers don't advertise. + let useTrustedServers = + trustedServers.length > 0 && !this.bootedFromLegacyRealmsList; // The legacy `app.boxel.realms` AccountData event is re-emitted by // the matrix sync that runs inside `startClient()` below. Setting // this flag here makes that re-emission a no-op for the available- // realms list — the realm-servers path is the authoritative source. - this.trustedRealmServersAuthoritative = trustedServers.length > 0; + this.trustedRealmServersAuthoritative = useTrustedServers; let userRealmURLs: string[]; - if (trustedServers.length > 0) { + if (useTrustedServers) { if (isTesting()) console.warn('[start-phase] fetchUserRealmsFromTrustedServers'); userRealmURLs = @@ -930,6 +946,7 @@ export default class MatrixService extends Service { trustedServers, ); } else { + this.bootedFromLegacyRealmsList = true; if (isTesting()) console.warn('[start-phase] getAccountData(realms-legacy)'); let legacyRealmsData = (await this.client.getAccountDataFromServer( @@ -943,12 +960,12 @@ export default class MatrixService extends Service { // origins backing the user's existing realm URLs so future boots // take the authoritative trusted-servers assembly path. The legacy // `app.boxel.realms` key is intentionally retained for rollback - // safety during the transition window. Once written the new key is - // non-empty, so subsequent boots skip this branch — the migration - // fires at most once per account, and is a no-op for an account - // with no realms (nothing to derive). Best-effort: a derivation - // failure must not break boot. - if (userRealmURLs.length > 0) { + // safety during the transition window. Gated on `trustedServers` + // being genuinely empty so a re-boot of this already-legacy session + // (where the key we just wrote is now present) doesn't re-write it. + // A no-op for an account with no realms (nothing to derive). + // Best-effort: a derivation failure must not break boot. + if (trustedServers.length === 0 && userRealmURLs.length > 0) { try { let derivedRealmServers = this.realmServer.deriveRealmServerURLsForRealms(userRealmURLs); @@ -1834,6 +1851,7 @@ export default class MatrixService extends Service { ); } this.postLoginCompleted = false; + this.bootedFromLegacyRealmsList = false; this._isLoadingMoreAIRooms = false; this.messagesToSend.clear(); this.cardsToSend.clear(); diff --git a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts index 34b700e125b..46d7dd01c24 100644 --- a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -245,6 +245,28 @@ module( 'testRealmURL is present in availableRealmIdentifiers', ); }); + + test('a re-boot of the same session after migration stays on the legacy path', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + let realmServer = getService('realm-server') as RealmServerService; + + // The first boot (beforeEach) migrated and wrote `app.boxel.realm-servers`. + // Re-booting the same MatrixService instance must keep assembling from the + // legacy realm list rather than switching to the trusted-servers path, + // which would re-derive the list from `_realm-auth`. The migration only + // takes effect on the next fresh session. + await matrixService.start(); + + assert.deepEqual( + await matrixService.getRealmServersFromAccountData(), + [normalizedTrustedServerURL], + 'realm-servers stays as migrated — not re-derived or duplicated', + ); + assert.ok( + realmServer.availableRealmIdentifiers.includes(ri(testRealmURL)), + 'testRealmURL from the legacy list survives the re-boot', + ); + }); }, ); From 586e7384c8a0b3b23b551747cf8ce7c6c28845c0 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 23 Jun 2026 14:41:48 -0400 Subject: [PATCH 4/6] Derive migration realm-servers from JWT claim/own-server, not realm origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a boot-regression risk: deriving a realm's server from the bare realm-URL origin can persist a foreign origin into app.boxel.realm-servers (e.g. the shared base realm at cardstack.com, or a custom-domain realm), because a realm's content origin can differ from its realm server. On the next fresh boot the trusted-servers path runs assertOwnRealmServer before any fallback, rejects the non-own/multi-origin list, and start()'s catch logs the user out. Reuse getRealmServersForRealms, which derives the server from each realm's JWT realmServerURL claim and falls back to this host's own realm server, never the realm-URL origin (token-less realms are skipped, not guessed). Drop the origin-based deriveRealmServerURLsForRealms and its unit tests (they couldn't catch this — in the test env every relevant origin collapses to localhost:4201, which is why it slipped through). Co-Authored-By: Claude Opus 4.8 --- packages/host/app/services/matrix-service.ts | 26 +++++++----- packages/host/app/services/realm-server.ts | 23 ----------- .../matrix-service-boot-assembly-test.ts | 9 +++-- .../matrix-service-realm-servers-test.ts | 40 ------------------- 4 files changed, 21 insertions(+), 77 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 92d30e9c60d..55080155a44 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -956,19 +956,25 @@ export default class MatrixService extends Service { // Lazy migration: this account predates `app.boxel.realm-servers` // (the key was absent or empty, so boot fell back to the legacy - // realm list above). Seed the new key from the trusted realm-server - // origins backing the user's existing realm URLs so future boots - // take the authoritative trusted-servers assembly path. The legacy - // `app.boxel.realms` key is intentionally retained for rollback - // safety during the transition window. Gated on `trustedServers` - // being genuinely empty so a re-boot of this already-legacy session - // (where the key we just wrote is now present) doesn't re-write it. - // A no-op for an account with no realms (nothing to derive). - // Best-effort: a derivation failure must not break boot. + // realm list above). Seed the new key with the realm-server backing + // the user's existing realms so future boots take the authoritative + // trusted-servers assembly path. We use `getRealmServersForRealms`, + // which derives the server from each realm's JWT `realmServerURL` + // claim and falls back to this host's own realm server — never the + // bare realm-URL origin. That matters because a realm URL's origin + // can differ from its realm server (e.g. the shared base realm at + // cardstack.com); persisting such a foreign origin would make the + // next boot's `assertOwnRealmServer` reject the list and log the + // user out. The legacy `app.boxel.realms` key is intentionally + // retained for rollback safety during the transition window. Gated + // on `trustedServers` being genuinely empty so a re-boot of this + // already-legacy session (where the key we just wrote is now + // present) doesn't re-write it. A no-op for an account with no + // realms. Best-effort: a failure must not break boot. if (trustedServers.length === 0 && userRealmURLs.length > 0) { try { let derivedRealmServers = - this.realmServer.deriveRealmServerURLsForRealms(userRealmURLs); + this.realmServer.getRealmServersForRealms(userRealmURLs); if (derivedRealmServers.length > 0) { if (isTesting()) console.warn('[start-phase] migrateRealmServersAccountData'); diff --git a/packages/host/app/services/realm-server.ts b/packages/host/app/services/realm-server.ts index de7ebe2f7a9..670f7f738e1 100644 --- a/packages/host/app/services/realm-server.ts +++ b/packages/host/app/services/realm-server.ts @@ -364,29 +364,6 @@ export default class RealmServerService extends Service { return [...realmServerURLs]; } - // Derives the distinct realm-server URLs backing the given realm URLs. - // Used by the one-time host-boot migration that seeds - // `app.boxel.realm-servers` from the legacy `app.boxel.realms` list. A - // realm's server is the origin of its URL; when a session token for that - // realm is present its `realmServerURL` claim is authoritative and taken - // as a cross-check over the bare origin. Results run through the same - // normalization as the rest of the service, so the distinct origins that - // back a single server (e.g. the test-realm origin and the base realm - // origin in tests) collapse to that one server. - deriveRealmServerURLsForRealms(realms: string[]): string[] { - let sessionTokens = this.readSessionTokens(); - let realmServerURLs = new Set(); - for (let realmURL of realms) { - let normalizedRealmURL = ensureTrailingSlash(realmURL); - let token = sessionTokens[normalizedRealmURL] ?? sessionTokens[realmURL]; - let claims = token ? realmClaimsFromRawToken(token) : undefined; - let realmServerURL = - claims?.realmServerURL ?? new URL(normalizedRealmURL).origin; - realmServerURLs.add(this.normalizeRealmServerURL(realmServerURL)); - } - return [...realmServerURLs]; - } - private readSessionTokens(): Record { let sessionStr = window.localStorage.getItem(SessionLocalStorageKey) ?? '{}'; diff --git a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts index 46d7dd01c24..8d80f7e31d2 100644 --- a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -191,8 +191,9 @@ module( // CS-11659: lazy migration on host boot. A user who predates // `app.boxel.realm-servers` (only `app.boxel.realms` set) has the new key -// seeded on next boot from the realm-server origins backing their existing -// realm URLs. The legacy key is retained for rollback safety. +// seeded on next boot with the realm-server backing their existing realms +// (derived via JWT `realmServerURL` claim / own-server fallback, never the +// bare realm-URL origin). The legacy key is retained for rollback safety. module( 'Integration | matrix-service | lazy migration seeds realm-servers', function (hooks) { @@ -220,13 +221,13 @@ module( await matrixService.start(); }); - test('boot writes `app.boxel.realm-servers` derived from the realm origins', async function (assert) { + test('boot writes `app.boxel.realm-servers` with the backing realm-server', async function (assert) { let matrixService = getService('matrix-service') as MatrixService; let realmServers = await matrixService.getRealmServersFromAccountData(); assert.deepEqual( realmServers, [normalizedTrustedServerURL], - 'the trusted realm-server backing testRealmURL is persisted', + 'the realm-server backing testRealmURL is persisted (own server, not the base-realm origin)', ); }); diff --git a/packages/host/tests/integration/matrix-service-realm-servers-test.ts b/packages/host/tests/integration/matrix-service-realm-servers-test.ts index e838d8241c5..b15e4e1c4e5 100644 --- a/packages/host/tests/integration/matrix-service-realm-servers-test.ts +++ b/packages/host/tests/integration/matrix-service-realm-servers-test.ts @@ -1,14 +1,11 @@ import type { RenderingTestContext } from '@ember/test-helpers'; import { getService } from '@universal-ember/test-support'; -import window from 'ember-window-mock'; import { module, test } from 'qunit'; import { baseRealm } from '@cardstack/runtime-common'; import type MatrixService from '@cardstack/host/services/matrix-service'; -import type RealmServerService from '@cardstack/host/services/realm-server'; -import { SessionLocalStorageKey } from '@cardstack/host/utils/local-storage-keys'; import { testRealmURL, @@ -113,42 +110,5 @@ module( 'removing a non-existent server leaves the list unchanged', ); }); - - // CS-11659: the lazy boot migration derives the realm-server URLs to seed - // into `app.boxel.realm-servers`. These exercise the derivation directly - // with non-test origins so `normalizeRealmServerURL` is a no-op and the - // origin-vs-claim resolution is observable. - test('deriveRealmServerURLsForRealms uses the realm URL origin when no token is present', async function (assert) { - let realmServer = getService('realm-server') as RealmServerService; - window.localStorage.removeItem(SessionLocalStorageKey); - - assert.deepEqual( - realmServer.deriveRealmServerURLsForRealms([ - 'https://content.example/my-realm/', - 'https://content.example/another-realm/', - ]), - ['https://content.example/'], - 'distinct realms sharing an origin collapse to a single server', - ); - }); - - test('deriveRealmServerURLsForRealms prefers the JWT realmServerURL claim over the origin', async function (assert) { - let realmServer = getService('realm-server') as RealmServerService; - let realmURL = 'https://content.example/my-realm/'; - // A realm whose content is served from one origin but whose JWT names a - // different realm-server origin — the claim is authoritative. - let claim = { realmServerURL: 'https://api.example/' }; - let token = `header.${btoa(JSON.stringify(claim))}.signature`; - window.localStorage.setItem( - SessionLocalStorageKey, - JSON.stringify({ [realmURL]: token }), - ); - - assert.deepEqual( - realmServer.deriveRealmServerURLsForRealms([realmURL]), - ['https://api.example/'], - 'the realmServerURL claim cross-checks and overrides the bare origin', - ); - }); }, ); From f8c0161a8a6a0b46c524b43ca03e4c11f23f022c Mon Sep 17 00:00:00 2001 From: ylm Date: Tue, 23 Jun 2026 16:52:59 -0400 Subject: [PATCH 5/6] Make lazy-migration comments evergreen Drop ticket-ID prefixes and temporal phrasing ("predates", "transition window", "not-yet-migrated") in the lazy-migration block and the two related test modules. Same technical content, stated as current contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/host/app/services/matrix-service.ts | 34 +++++++++---------- .../matrix-service-boot-assembly-test.ts | 12 +++---- .../matrix-service-realm-servers-test.ts | 6 ++-- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 55080155a44..9f90bdab153 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -954,23 +954,23 @@ export default class MatrixService extends Service { )) as { realms: string[] } | null; userRealmURLs = legacyRealmsData?.realms ?? []; - // Lazy migration: this account predates `app.boxel.realm-servers` - // (the key was absent or empty, so boot fell back to the legacy - // realm list above). Seed the new key with the realm-server backing - // the user's existing realms so future boots take the authoritative - // trusted-servers assembly path. We use `getRealmServersForRealms`, - // which derives the server from each realm's JWT `realmServerURL` - // claim and falls back to this host's own realm server — never the - // bare realm-URL origin. That matters because a realm URL's origin - // can differ from its realm server (e.g. the shared base realm at - // cardstack.com); persisting such a foreign origin would make the - // next boot's `assertOwnRealmServer` reject the list and log the - // user out. The legacy `app.boxel.realms` key is intentionally - // retained for rollback safety during the transition window. Gated - // on `trustedServers` being genuinely empty so a re-boot of this - // already-legacy session (where the key we just wrote is now - // present) doesn't re-write it. A no-op for an account with no - // realms. Best-effort: a failure must not break boot. + // Lazy migration: this account has no `app.boxel.realm-servers` + // entry (the key was absent or empty, so boot fell back to the + // legacy realm list above). Seed the new key with the realm-server + // backing the user's existing realms so subsequent boots take the + // authoritative trusted-servers assembly path. We use + // `getRealmServersForRealms`, which derives the server from each + // realm's JWT `realmServerURL` claim and falls back to this host's + // own realm server — never the bare realm-URL origin. That matters + // because a realm URL's origin can differ from its realm server + // (e.g. the shared base realm at cardstack.com); persisting such a + // foreign origin would make the next boot's `assertOwnRealmServer` + // reject the list and log the user out. The legacy + // `app.boxel.realms` key is intentionally retained for rollback + // safety. Gated on `trustedServers` being genuinely empty so a + // re-boot of this same legacy session (where the key we just wrote + // is now present) doesn't re-write it. A no-op for an account with + // no realms. Best-effort: a failure must not break boot. if (trustedServers.length === 0 && userRealmURLs.length > 0) { try { let derivedRealmServers = diff --git a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts index 8d80f7e31d2..e5493b0df4a 100644 --- a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -189,11 +189,11 @@ module( }, ); -// CS-11659: lazy migration on host boot. A user who predates -// `app.boxel.realm-servers` (only `app.boxel.realms` set) has the new key -// seeded on next boot with the realm-server backing their existing realms -// (derived via JWT `realmServerURL` claim / own-server fallback, never the -// bare realm-URL origin). The legacy key is retained for rollback safety. +// Lazy migration on host boot. An account with no `app.boxel.realm-servers` +// entry (only `app.boxel.realms` set) has the new key seeded on next boot +// with the realm-server backing its existing realms (derived via JWT +// `realmServerURL` claim / own-server fallback, never the bare realm-URL +// origin). The legacy key is retained for rollback safety. module( 'Integration | matrix-service | lazy migration seeds realm-servers', function (hooks) { @@ -202,7 +202,7 @@ module( setupLocalIndexing(hooks); // Only the legacy `app.boxel.realms` key is set (no activeRealmServers), - // matching a not-yet-migrated account. + // matching an unmigrated account. let mockMatrixUtils = setupMockMatrix(hooks, { loggedInAs: '@testuser:localhost', activeRealms: [testRealmURL], diff --git a/packages/host/tests/integration/matrix-service-realm-servers-test.ts b/packages/host/tests/integration/matrix-service-realm-servers-test.ts index b15e4e1c4e5..f399eeda05b 100644 --- a/packages/host/tests/integration/matrix-service-realm-servers-test.ts +++ b/packages/host/tests/integration/matrix-service-realm-servers-test.ts @@ -42,9 +42,9 @@ module( mockMatrixUtils, contents: {}, }); - // Boot's lazy migration (CS-11659) seeds `app.boxel.realm-servers` from - // the active realms, so reset to a known-empty starting point — these - // tests exercise the raw read/write helpers, not the migration. + // Boot's lazy migration seeds `app.boxel.realm-servers` from the active + // realms, so reset to a known-empty starting point — these tests + // exercise the raw read/write helpers, not the migration. let matrixService = getService('matrix-service') as MatrixService; await matrixService.setRealmServersInAccountData([]); }); From 3588c5b6730e16cfd94ce6729a429743600e8f83 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 23 Jun 2026 23:22:29 -0400 Subject: [PATCH 6/6] Suppress realm-servers AccountData handling on the legacy boot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy migration writes `app.boxel.realm-servers` during start() and that write echoes back through the AccountData listener — both synchronously and again when startClient()'s initial sync re-emits account data. The narrow `migratingRealmServersAccountData` flag only covered the synchronous self- write, so the startClient echo ran trusted-servers assembly mid-boot and overwrote the legacy-assembled realm list, defeating the sticky-legacy-path guarantee. Guard the listener on `bootedFromLegacyRealmsList` instead: it is set before the migration write and persists for the instance's lifetime, so it covers both echoes and matches start()'s instance-lifetime stickiness. The narrow flag is now redundant and removed. The mock matrix client only re-emitted the legacy realms event on startClient, which hid the bug; re-emit `app.boxel.realm-servers` too, mirroring the real initial sync, and add a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/app/services/matrix-service.ts | 46 +++++++++++-------- .../host/tests/helpers/mock-matrix/_client.ts | 13 ++++++ .../matrix-service-boot-assembly-test.ts | 25 ++++++++++ 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index 9f90bdab153..02b8ee78954 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -188,12 +188,6 @@ export default class MatrixService extends Service { // the migration only takes effect on the next fresh session. Reset by // resetState() so a logout/login re-evaluates against the persisted key. private bootedFromLegacyRealmsList = false; - // Set only while the boot-time lazy migration writes `app.boxel.realm-servers` - // itself, so the AccountData listener ignores that self-write rather than - // re-running trusted-servers assembly mid-boot. The migration has already - // assembled this session from the equivalent legacy realm list; the new key - // only needs to take effect on the next boot. - private migratingRealmServersAccountData = false; @tracked private _currentRoomId: string | undefined; @tracked private timelineLoadingState: Map = new TrackedMap(); @@ -433,11 +427,16 @@ export default class MatrixService extends Service { break; } case APP_BOXEL_REALM_SERVERS_EVENT_TYPE: { - // The boot-time lazy migration's own write echoes back here; it - // has already assembled this session from the equivalent legacy - // realm list, so ignore the self-write rather than re-running - // assembly mid-boot. - if (this.migratingRealmServersAccountData) { + // A session that booted from the legacy `app.boxel.realms` list + // stays on the legacy path for the lifetime of this instance + // (see `bootedFromLegacyRealmsList` in start()). The boot-time + // lazy migration writes `app.boxel.realm-servers`, and that write + // echoes back here — both synchronously and again when + // startClient()'s initial sync re-emits account data. Ignoring + // these keeps the migrated key from re-running trusted-servers + // assembly mid-boot and overwriting the legacy-assembled realm + // list; the new key only takes effect on the next fresh session. + if (this.bootedFromLegacyRealmsList) { break; } let realmServers = e.event.content.realmServers as string[]; @@ -496,6 +495,17 @@ export default class MatrixService extends Service { }; } + // Test-only diagnostic exposing which boot path the current session is on. + // A legacy-booted session must stay non-authoritative even after the lazy + // migration writes `app.boxel.realm-servers` and that write echoes back + // through the AccountData listener. No production caller. + get bootAssemblyDebug() { + return { + trustedRealmServersAuthoritative: this.trustedRealmServersAuthoritative, + bootedFromLegacyRealmsList: this.bootedFromLegacyRealmsList, + }; + } + private get client() { if (!this._client) { throw new Error(`cannot use matrix client before matrix SDK has loaded`); @@ -978,16 +988,12 @@ export default class MatrixService extends Service { if (derivedRealmServers.length > 0) { if (isTesting()) console.warn('[start-phase] migrateRealmServersAccountData'); - // Guard so this self-write doesn't re-trigger trusted-servers - // assembly via the AccountData listener while boot is still - // running — this session is already assembled from the legacy + // `bootedFromLegacyRealmsList` is already set above, so the + // AccountData listener ignores both this self-write and the + // echo from startClient()'s initial sync — no extra guard + // needed. This session is already assembled from the legacy // list; the new key takes effect on the next boot. - this.migratingRealmServersAccountData = true; - try { - await this.setRealmServersInAccountData(derivedRealmServers); - } finally { - this.migratingRealmServersAccountData = false; - } + await this.setRealmServersInAccountData(derivedRealmServers); } } catch (err) { console.error( diff --git a/packages/host/tests/helpers/mock-matrix/_client.ts b/packages/host/tests/helpers/mock-matrix/_client.ts index 165c45a801b..3e6f176fb42 100644 --- a/packages/host/tests/helpers/mock-matrix/_client.ts +++ b/packages/host/tests/helpers/mock-matrix/_client.ts @@ -145,6 +145,11 @@ export class MockClient implements ExtendedClient { }), ); + // The real matrix initial sync re-emits every account-data key, not just + // the legacy realms list. Re-emit both so boot-path handlers see the same + // echoes they would in production — notably the `app.boxel.realm-servers` + // echo a lazy-migration boot triggers by writing that key just before + // startClient(). this.emitEvent( new MatrixEvent({ type: APP_BOXEL_REALMS_EVENT_TYPE, @@ -153,6 +158,14 @@ export class MockClient implements ExtendedClient { }, }), ); + this.emitEvent( + new MatrixEvent({ + type: APP_BOXEL_REALM_SERVERS_EVENT_TYPE, + content: { + realmServers: this.sdkOpts.activeRealmServers ?? [], + }, + }), + ); } async createRealmSession(realmURL: URL): Promise { diff --git a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts index e5493b0df4a..7ecdb6e192e 100644 --- a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -247,6 +247,31 @@ module( ); }); + test('the migration self-write echo does not flip the boot to the trusted path', async function (assert) { + // The migration writes `app.boxel.realm-servers` during start(), and that + // write echoes back through the AccountData listener twice: synchronously + // from setAccountData, and again when startClient()'s initial sync + // re-emits every account-data key. Neither echo may switch this + // legacy-booted session to the authoritative trusted-servers path — + // doing so would re-derive the realm list from `_realm-auth` and could + // drop realms the trusted servers don't advertise. + let matrixService = getService('matrix-service') as MatrixService; + let realmServer = getService('realm-server') as RealmServerService; + + assert.deepEqual( + matrixService.bootAssemblyDebug, + { + trustedRealmServersAuthoritative: false, + bootedFromLegacyRealmsList: true, + }, + 'the session stays on the legacy path despite the realm-servers echo', + ); + assert.ok( + realmServer.availableRealmIdentifiers.includes(ri(testRealmURL)), + 'the legacy-assembled realm survives the echo', + ); + }); + test('a re-boot of the same session after migration stays on the legacy path', async function (assert) { let matrixService = getService('matrix-service') as MatrixService; let realmServer = getService('realm-server') as RealmServerService;