diff --git a/packages/host/app/services/matrix-service.ts b/packages/host/app/services/matrix-service.ts index e5cf963d493..49af498df43 100644 --- a/packages/host/app/services/matrix-service.ts +++ b/packages/host/app/services/matrix-service.ts @@ -954,6 +954,80 @@ export default class MatrixService extends Service { await this.appendRealmToAccountData(personalRealmURL.href); } + // Whether login auto-provisions a personal workspace for a user who has none. + // On for any real deployment, off only in tests: `hostedEnvironment` is the + // realm-server's serve-time environment and is `local` in both the QUnit + // suite and the matrix e2e host (the realm server only overwrites it when it + // has a REALM_SENTRY_ENVIRONMENT), so provisioning never fires in tests but + // does on staging, production, and any self-hosted deployment. A settable + // field (not a getter) so a test can flip it on and exercise the real + // start()->provision path. + autoProvisionPersonalRealm = ENV.hostedEnvironment !== 'local'; + + // Fire-and-forget wrapper so boot doesn't block on realm creation and so a + // re-entrant start() never launches a second one. `dropTask` also lets a test + // await settled() for the provisioning to finish. + private ensurePersonalRealmTask = dropTask(async () => { + await this.ensurePersonalRealmForUserIfMissing(); + }); + + // Give the signed-in user a personal workspace when they have none — the case + // for an account provisioned outside host sign-up (e.g. registered straight + // on Synapse). Idempotent: `_create-realm` rejects a duplicate with "already + // exists", which we treat as success. + async ensurePersonalRealmForUserIfMissing(): Promise { + // Without a username we can't derive which `/personal/` realm is this + // user's own, and the server derives ownership from the JWT anyway — skip. + let username = this.userName; + if (!username) { + return; + } + // Match the user's *own* personal realm, not anyone's. A `/personal/` realm + // shared with this user (a grant into someone else's workspace — exactly + // what this PR's grant machinery enables) must not suppress provisioning. + // Mirror the server's URL derivation (create-realm.ts): a relative path + // resolved against the realm server's own (trailing-slash) origin. + let ownPersonalRealmURL = new URL( + `${username}/${PERSONAL_REALM_ENDPOINT}/`, + this.realmServer.url, + ).href; + let hasPersonalRealm = this.realmServer.userRealmIdentifiers.some( + (id) => String(id) === ownPersonalRealmURL, + ); + if (hasPersonalRealm) { + return; + } + let displayName: string | undefined; + try { + displayName = this.userId + ? (await this.getProfileInfo(this.userId))?.displayname + : undefined; + } catch { + // A profile fetch hiccup must not block provisioning; fall back below. + } + let name = displayName ? `${displayName}'s Workspace` : 'My Workspace'; + let iconSeed = displayName ?? 'workspace'; + try { + await this.createPersonalRealmForUser({ + endpoint: PERSONAL_REALM_ENDPOINT, + name, + iconURL: iconURLFor(iconSeed), + backgroundURL: getRandomBackgroundURL(), + }); + } catch (e: any) { + // Idempotency backstop for a race against a concurrent tab or a prior + // login: `_create-realm` rejects a duplicate endpoint with the phrase + // "already exists" (realm-server create-realm.ts), which host + // `createRealm` wraps into the thrown message. The own-URL check above + // already prevents this in the common case, so this branch is a rarely- + // hit backstop; treat that one phrase as success and surface anything + // else. + if (!String(e?.message ?? '').includes('already exists')) { + console.error('Failed to provision personal realm on login', e); + } + } + } + public async appendRealmToAccountData(realmURLString: string) { let { realms = [] } = ((await this.client.getAccountDataFromServer( @@ -1152,6 +1226,29 @@ 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 ?? []; + // An account registered straight on Synapse (shared-secret batch + // creation) never goes through host sign-up, so it has neither + // account-data key — yet it may already hold realm permissions. With + // no trusted server to ask, boot would assemble an empty chooser and + // ignore those permissions. Tentatively assemble from this host's own + // realm server via `_realm-auth`. Scoped to accounts with no realm list + // at all: one that already has a legacy `app.boxel.realms` list keeps + // its existing assembly + lazy-migration path untouched. The seed is + // only persisted (and the session only flipped to the authoritative + // trusted path) once `_realm-auth` confirms the account actually holds + // permissioned realms — see the revert below — so an account with none + // stays on the legacy path where account-data updates (including + // foreign realm URLs the trusted path can't serve) still drive its list. + let seededOwnRealmServer = false; + if (trustedServers.length === 0) { + let seedRealmsData = (await this.client.getAccountDataFromServer( + APP_BOXEL_REALMS_EVENT_TYPE, + )) as { realms: string[] } | null; + if ((seedRealmsData?.realms ?? []).length === 0) { + trustedServers = [this.realmServer.url.href]; + seededOwnRealmServer = true; + } + } // 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 @@ -1160,14 +1257,14 @@ export default class MatrixService extends Service { // 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 = + const 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 = useTrustedServers; - let userRealmURLs: string[]; + let userRealmURLs: string[] = []; if (useTrustedServers) { if (isTesting()) console.warn('[start-phase] fetchUserRealmsFromTrustedServers'); @@ -1175,7 +1272,8 @@ export default class MatrixService extends Service { await this.realmServer.fetchUserRealmsFromTrustedServers( trustedServers, ); - } else { + } + if (!useTrustedServers) { this.bootedFromLegacyRealmsList = true; if (isTesting()) console.warn('[start-phase] getAccountData(realms-legacy)'); @@ -1237,6 +1335,44 @@ export default class MatrixService extends Service { this.realmServer.setAvailableRealmIdentifiers(userRealmURLs.map(ri)), ]); + // Commit or undo the keyless-account seed now that the list is + // assembled. `_realm-auth` returns the public base/catalog realms for + // every authenticated user, so a non-empty trusted result does NOT mean + // the account has a workspace of its own — those realms dedup into the + // base/catalog entries and leave `userRealmIdentifiers` empty. Only keep + // the account on the authoritative trusted path (and persist the seed) + // when it actually has a user realm; otherwise leave it on the legacy + // `app.boxel.realms` path so account-data updates (including foreign + // realm URLs the trusted path can't serve) keep driving its list. + if (seededOwnRealmServer) { + if (this.realmServer.userRealmIdentifiers.length === 0) { + this.trustedRealmServersAuthoritative = false; + this.bootedFromLegacyRealmsList = true; + if (isTesting()) + console.warn('[start-phase] seed-revert getAccountData(realms)'); + let legacyRealmsData = (await this.client.getAccountDataFromServer( + APP_BOXEL_REALMS_EVENT_TYPE, + )) as { realms: string[] } | null; + userRealmURLs = legacyRealmsData?.realms ?? []; + await this.realmServer.setAvailableRealmIdentifiers( + userRealmURLs.map(ri), + ); + } else { + // Real user realms found: persist the seed so subsequent boots take + // the trusted path directly. Best-effort — assembly this boot + // already used the in-memory list, and the next login re-seeds if + // this write is lost. + try { + await this.setRealmServersInAccountData(trustedServers); + } catch (err) { + console.error( + 'Failed to seed app.boxel.realm-servers with own realm server', + err, + ); + } + } + } + if (isTesting()) console.warn('[start-phase] prefetchRealmInfos'); await this.realm.prefetchRealmInfos( this.realmServer.availableRealmIdentifiers, @@ -1311,6 +1447,21 @@ export default class MatrixService extends Service { // the reachable realms and retry the unreachable ones in the // background so they load (and the notice clears) once they recover. this.scheduleUnreachableRealmServerRetry(); + + // Ensure a personal workspace exists for an account provisioned outside + // host sign-up (e.g. registered straight on Synapse). Non-blocking, and + // the new realm surfaces live without a reload by whichever channel this + // session listens on: a session on the authoritative trusted path picks + // up the `realms-list-updated` event `_create-realm` emits (via + // `refreshRealmsList` -> re-run `_realm-auth`); a session on the legacy + // path (the keyless account with no realms of its own, this feature's + // primary target) picks up the `app.boxel.realms` write that + // `createPersonalRealmForUser` appends, via the AccountData listener. + // Off by default in the test suites (see autoProvisionPersonalRealm), + // and skipped for the new-user flow, which already creates one. + if (this.autoProvisionPersonalRealm && !this._isInitializingNewUser) { + this.ensurePersonalRealmTask.perform(); + } } catch (e) { console.log('Error starting Matrix client', e); // Only tear the session down for a failure that happened before this @@ -1501,7 +1652,10 @@ export default class MatrixService extends Service { try { let realmServers = await this.getRealmServersFromAccountData(); if (realmServers.length === 0) { - return; + // No persisted trusted-servers entry (e.g. the login-time seed write + // was lost): fall back to this host's own realm server so a live grant + // still re-assembles from permissions rather than being dropped. + realmServers = [this.realmServer.url.href]; } await this.applyTrustedRealmServersAccountData(realmServers); if (this.realmServer.isArchivedRealmsFetched) { 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 f08af1aaeab..3aba2c074ca 100644 --- a/packages/host/tests/integration/matrix-service-boot-assembly-test.ts +++ b/packages/host/tests/integration/matrix-service-boot-assembly-test.ts @@ -470,3 +470,112 @@ module( }); }, ); + +// An account registered straight on Synapse (shared-secret batch creation) +// never goes through host sign-up, so it has neither account-data key — yet it +// may already hold realm permissions. Boot seeds the trusted-servers key with +// the host's own realm server so the permissions-driven assembly (`_realm-auth`) +// runs on the very first login, instead of assembling an empty chooser. +module( + 'Integration | matrix-service | boot seeds own realm server for a keyless account', + function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + // Neither `app.boxel.realms` nor `app.boxel.realm-servers` is set — the + // shape a Synapse-registered account boots with. + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + }); + + hooks.beforeEach(async function (this: RenderingTestContext) { + // `setupIntegrationTestRealm` advertises testRealmURL through `_realm-auth` + // (i.e. the account holds permissions on it) independent of account data. + 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 assembles the permissioned realm from `_realm-auth`', async function (assert) { + let realmServer = getService('realm-server') as RealmServerService; + assert.ok( + realmServer.availableRealmIdentifiers.includes(ri(testRealmURL)), + 'testRealmURL from _realm-auth appears despite no account-data keys', + ); + }); + + test('boot persists the own realm server into `app.boxel.realm-servers`', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + assert.deepEqual( + await matrixService.getRealmServersFromAccountData(), + [testRealmServerURL], + 'the trusted-servers key is seeded with the host’s own realm server', + ); + }); + + test('boot takes the authoritative trusted-servers path', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + assert.deepEqual( + matrixService.bootAssemblyDebug, + { + trustedRealmServersAuthoritative: true, + bootedFromLegacyRealmsList: false, + }, + 'permissions are the source of truth — no legacy-list dependency', + ); + }); + }, +); + +// The conservative-scope guard: an account that already has a legacy +// `app.boxel.realms` list must NOT be seeded onto the trusted path by the new +// code — it keeps its existing legacy assembly + lazy-migration behavior. (The +// migration outcome itself is covered by the lazy-migration module above; this +// asserts the seed specifically stays out of the way.) +module( + 'Integration | matrix-service | boot seed leaves a legacy account alone', + function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + // Legacy `app.boxel.realms` set, no `app.boxel.realm-servers`. + 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('the account stays on the legacy path (the seed does not fire)', async function (assert) { + let matrixService = getService('matrix-service') as MatrixService; + assert.deepEqual( + matrixService.bootAssemblyDebug, + { + trustedRealmServersAuthoritative: false, + bootedFromLegacyRealmsList: true, + }, + 'a legacy-list account is untouched by the keyless seed', + ); + }); + }, +); diff --git a/packages/host/tests/integration/matrix-service-personal-realm-test.ts b/packages/host/tests/integration/matrix-service-personal-realm-test.ts new file mode 100644 index 00000000000..1ca23c7ab52 --- /dev/null +++ b/packages/host/tests/integration/matrix-service-personal-realm-test.ts @@ -0,0 +1,214 @@ +import type { RenderingTestContext } from '@ember/test-helpers'; +import { settled } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { baseRealm, ensureTrailingSlash } from '@cardstack/runtime-common'; +import { PERSONAL_REALM_ENDPOINT } from '@cardstack/runtime-common/realm-display-defaults'; + +import ENV from '@cardstack/host/config/environment'; +import type MatrixService from '@cardstack/host/services/matrix-service'; +import type RealmServerService from '@cardstack/host/services/realm-server'; + +import { + testRealmURL, + setupAuthEndpoints, + setupIntegrationTestRealm, + setupLocalIndexing, +} from '../helpers'; + +import { setupBaseRealm } from '../helpers/base-realm'; + +import { setupMockMatrix } from '../helpers/mock-matrix'; + +import { setupRenderingTest } from '../helpers/setup'; + +const testRealmServerURL = ensureTrailingSlash(ENV.realmServerURL); +// A personal workspace is served at `//personal/`. +const personalRealmURL = ensureTrailingSlash( + `${new URL(testRealmURL).origin}/testuser/${PERSONAL_REALM_ENDPOINT}/`, +); +// Another user's personal workspace, shared with @testuser. Same `/personal/` +// suffix, different owner — must not be mistaken for @testuser's own. +const otherUsersPersonalRealmURL = ensureTrailingSlash( + `${new URL(testRealmURL).origin}/otheruser/${PERSONAL_REALM_ENDPOINT}/`, +); + +// The host sign-up flow creates a personal workspace, but an account +// provisioned another way (e.g. registered straight on Synapse) never gets one. +// On login, `autoProvisionPersonalRealm` provisions it when absent. The flag +// defaults off in the test environment, so these tests flip it on to exercise +// the real boot path. +module( + 'Integration | matrix-service | personal realm auto-provisioning', + function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + activeRealmServers: [testRealmServerURL], + }); + + let createRealmCalls: Parameters[0][]; + + // Set up the realm + a createRealm capture, optionally enable + // auto-provisioning, then boot and let the fire-and-forget task settle. + async function boot(opts: { autoProvision: boolean }) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + startMatrix: false, + }); + let realmServer = getService('realm-server') as RealmServerService; + await realmServer.setAvailableRealmIdentifiers([]); + createRealmCalls = []; + // Capture calls instead of hitting `_create-realm` over the wire. + realmServer.createRealm = async (args) => { + createRealmCalls.push(args); + return new URL(personalRealmURL); + }; + let matrixService = getService('matrix-service') as MatrixService; + matrixService.autoProvisionPersonalRealm = opts.autoProvision; + await matrixService.ready; + await matrixService.start(); + await settled(); + } + + test('login does not provision when the flag is off (the test default)', async function (this: RenderingTestContext, assert) { + await boot({ autoProvision: false }); + assert.strictEqual( + createRealmCalls.length, + 0, + 'no realm is auto-created at boot when auto-provisioning is off', + ); + }); + + test('login provisions a personal realm with the personal endpoint when enabled', async function (this: RenderingTestContext, assert) { + await boot({ autoProvision: true }); + assert.strictEqual( + createRealmCalls.length, + 1, + 'createRealm was called exactly once at boot', + ); + assert.strictEqual( + createRealmCalls[0]?.endpoint, + PERSONAL_REALM_ENDPOINT, + 'with the personal endpoint', + ); + }); + }, +); + +// The inverse: a user who already has a personal workspace must not have a +// second one provisioned, even with the flag on. +module( + 'Integration | matrix-service | personal realm not duplicated', + function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, personalRealmURL], + activeRealmServers: [testRealmServerURL], + }); + + let createRealmCalls: Parameters[0][]; + + hooks.beforeEach(async function (this: RenderingTestContext) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + startMatrix: false, + }); + // `_realm-auth` advertises the existing personal realm, so boot's + // assembled list already contains it. + setupAuthEndpoints({ + [personalRealmURL]: ['read', 'write', 'realm-owner'], + }); + let realmServer = getService('realm-server') as RealmServerService; + await realmServer.setAvailableRealmIdentifiers([]); + createRealmCalls = []; + realmServer.createRealm = async (args) => { + createRealmCalls.push(args); + return new URL(personalRealmURL); + }; + let matrixService = getService('matrix-service') as MatrixService; + matrixService.autoProvisionPersonalRealm = true; + await matrixService.ready; + await matrixService.start(); + await settled(); + }); + + test('createRealm is not called when a personal realm already exists', async function (assert) { + assert.strictEqual( + createRealmCalls.length, + 0, + 'no personal realm is provisioned when one is already present', + ); + }); + }, +); + +// Someone else's `/personal/` realm shared with the user must NOT suppress +// provisioning of the user's own — the exact case a naive `endsWith('/personal/')` +// check got wrong. The user holds only `otheruser/personal/`, so their own +// `testuser/personal/` still gets created. +module( + 'Integration | matrix-service | personal realm provisioned despite a shared foreign one', + function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, otherUsersPersonalRealmURL], + activeRealmServers: [testRealmServerURL], + }); + + let createRealmCalls: Parameters[0][]; + + hooks.beforeEach(async function (this: RenderingTestContext) { + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: {}, + startMatrix: false, + }); + // `_realm-auth` advertises only the *other* user's personal realm. + setupAuthEndpoints({ + [otherUsersPersonalRealmURL]: ['read'], + }); + let realmServer = getService('realm-server') as RealmServerService; + await realmServer.setAvailableRealmIdentifiers([]); + createRealmCalls = []; + realmServer.createRealm = async (args) => { + createRealmCalls.push(args); + return new URL(personalRealmURL); + }; + let matrixService = getService('matrix-service') as MatrixService; + matrixService.autoProvisionPersonalRealm = true; + await matrixService.ready; + await matrixService.start(); + await settled(); + }); + + test('createRealm is called even though a foreign personal realm is present', async function (assert) { + assert.strictEqual( + createRealmCalls.length, + 1, + 'the user’s own personal realm is provisioned despite the shared one', + ); + assert.strictEqual( + createRealmCalls[0]?.endpoint, + PERSONAL_REALM_ENDPOINT, + 'with the personal endpoint', + ); + }); + }, +); diff --git a/packages/realm-server/handlers/create-realm.ts b/packages/realm-server/handlers/create-realm.ts index 2e802a9d09a..5b98dbeebab 100644 --- a/packages/realm-server/handlers/create-realm.ts +++ b/packages/realm-server/handlers/create-realm.ts @@ -27,7 +27,7 @@ import { realmReadmeTemplate, shouldSeedRealmReadme, } from '../lib/realm-readme.ts'; -import type { SendEvent } from './send-event.ts'; +import type { SendEvent } from '@cardstack/runtime-common/send-event'; import type { RealmRegistryReconciler } from '../lib/realm-registry-reconciler.ts'; import { fetchRequestFromContext, diff --git a/packages/realm-server/handlers/handle-upsert-realm-user-permission.ts b/packages/realm-server/handlers/handle-upsert-realm-user-permission.ts index 7f83542e34f..cd46e614fab 100644 --- a/packages/realm-server/handlers/handle-upsert-realm-user-permission.ts +++ b/packages/realm-server/handlers/handle-upsert-realm-user-permission.ts @@ -6,6 +6,7 @@ import { type RealmAction, SupportedMimeType, } from '@cardstack/runtime-common'; +import { REALMS_LIST_UPDATED_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants'; import { sendResponseForBadRequest, setContextResponse, @@ -88,6 +89,7 @@ export default function handleUpsertRealmUserPermission({ matrixClient, matrixAdminUsername, matrixAdminPassword, + sendEvent, }: CreateRoutesArgs): (ctxt: Koa.Context, next: Koa.Next) => Promise { return async function (ctxt: Koa.Context, _next: Koa.Next) { let realm = ctxt.URL.searchParams.get('realm'); @@ -154,6 +156,20 @@ export default function handleUpsertRealmUserPermission({ [user]: actions, }); + // Push a live "your realms changed" signal so a running session re-derives + // its list from `_realm-auth` without a reload. This also covers the case + // where the account-data append below is a no-op (server already present), + // which on its own produces no Matrix event. Best-effort: a delivery + // failure must not fail the grant, and `sendEvent` is itself a no-op when + // the user has no session room to deliver to. + try { + await sendEvent(user, REALMS_LIST_UPDATED_EVENT_TYPE); + } catch (e: any) { + log.warn( + `[grafana-upsert-realm-user-permission] failed to send ${REALMS_LIST_UPDATED_EVENT_TYPE} to ${user}: ${e?.message ?? String(e)}`, + ); + } + // The granted user only learns about the realm on their next host // load if it's present in their matrix `app.boxel.realms` // account_data — that's what the host reads to render the workspace diff --git a/packages/realm-server/handlers/send-event.ts b/packages/realm-server/handlers/send-event.ts deleted file mode 100644 index 1d09b1ba1ff..00000000000 --- a/packages/realm-server/handlers/send-event.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { DBAdapter } from '@cardstack/runtime-common'; -import { fetchSessionRoom, logger } from '@cardstack/runtime-common'; -import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; -import { APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE } from '@cardstack/runtime-common/matrix-constants'; - -const log = logger('realm-server:send-event'); - -export type SendEventDeps = { - matrixClient: MatrixClient; - dbAdapter: DBAdapter; -}; - -export type SendEvent = ( - user: string, - eventType: string, - data?: Record, -) => Promise; - -export function createSendEvent({ - matrixClient, - dbAdapter, -}: SendEventDeps): SendEvent { - return async function sendEvent(user, eventType, data) { - // The room lookup runs before any Matrix call: it is the step that can - // make this a no-op, and it is a local database read, so a user with - // nothing to receive never depends on the homeserver being reachable. - let roomId = await fetchSessionRoom(dbAdapter, user); - if (!roomId) { - // No session room means nowhere to deliver to. Usually the user has - // never established one, which is ordinary for a realm created by the - // CLI, by an admin, or by a test fixture. `clearSessionRoom` also nulls - // the column for a live session whose DM the realm server has left, - // until that session mints a fresh room on its next `_server-session` - // or realm auth — inside that window a notify the user would have - // received is dropped here, at a level nothing surfaces, so an absent - // row is not proof that nobody is listening. Either way there is - // nothing addressable, and callers treat the notify as best-effort. - log.debug( - `skipping ${eventType} for ${user}: no session room to deliver to`, - ); - return; - } - - if (!matrixClient.isLoggedIn()) { - await matrixClient.login(); - } - - await matrixClient.sendEvent(roomId, 'm.room.message', { - body: JSON.stringify({ eventType, data }), - msgtype: APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE, - }); - }; -} diff --git a/packages/realm-server/node-realm.ts b/packages/realm-server/node-realm.ts index a2c386f00c6..1a26cf86f4f 100644 --- a/packages/realm-server/node-realm.ts +++ b/packages/realm-server/node-realm.ts @@ -12,6 +12,7 @@ import { type TokenClaims, clearSessionRoom, fetchRealmSessionRooms, + isRealmServerNotInRoomError, } from '@cardstack/runtime-common'; import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; import type { LocalPath } from '@cardstack/runtime-common/paths'; @@ -60,40 +61,6 @@ function statIfExists(absolutePath: string): Stats | undefined { } } -function parseMatrixSendEventError(error: unknown): { - status?: number; - errcode?: string; - error?: string; -} | null { - if (!(error instanceof Error)) { - return null; - } - - let match = error.message.match(/status (\d+) - (\{.*\})$/); - if (!match) { - return null; - } - - let [, status, body] = match; - try { - return { - status: Number(status), - ...(JSON.parse(body) as { errcode?: string; error?: string }), - }; - } catch (_err) { - return { status: Number(status) }; - } -} - -function isRealmServerNotInRoomError(error: unknown, roomId: string): boolean { - let details = parseMatrixSendEventError(error); - return Boolean( - details?.status === 403 && - details?.errcode === 'M_FORBIDDEN' && - details?.error?.includes(`not in room ${roomId}`), - ); -} - export class NodeAdapter implements RealmAdapter { private realmDir: string; private enableFileWatcher?: boolean; diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index 195dd5f9f1b..faac5518443 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -34,7 +34,7 @@ import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; import { createRoutes } from './routes.ts'; import { JobScopedSearchCache } from './job-scoped-search-cache.ts'; import type { LiveSearchCache } from './live-search-cache.ts'; -import { createSendEvent } from './handlers/send-event.ts'; +import { createSendEvent } from '@cardstack/runtime-common/send-event'; import { createServeFromRealm } from './handlers/serve-from-realm.ts'; import { createServeIndex } from './handlers/serve-index.ts'; import { findOrMountRealm } from './lib/realm-routing.ts'; diff --git a/packages/realm-server/tests/realm-endpoints/permissions-test.ts b/packages/realm-server/tests/realm-endpoints/permissions-test.ts index 181bdb67f76..bc3b0c53046 100644 --- a/packages/realm-server/tests/realm-endpoints/permissions-test.ts +++ b/packages/realm-server/tests/realm-endpoints/permissions-test.ts @@ -14,9 +14,61 @@ import { testRealmURL, createJWT, waitUntil, + realmServerTestMatrix, + realmSecretSeed, } from '../helpers/index.ts'; import '@cardstack/runtime-common/helpers/code-equality-assertion'; import type { PgAdapter } from '@cardstack/postgres'; +import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; +import { + APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE, + REALMS_LIST_UPDATED_EVENT_TYPE, +} from '@cardstack/runtime-common/matrix-constants'; +import type { + MatrixEvent, + RealmServerEventContent, +} from '@cardstack/base/matrix-event'; +import { createRealmServerSession } from '../server-endpoints/helpers.ts'; + +function isRealmServerEventContent( + content: MatrixEvent['content'], +): content is RealmServerEventContent { + return ( + 'msgtype' in content && + (content.msgtype as string) === APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE + ); +} + +// Poll a session room for a `realms-list-updated` push. The realm server +// delivers it into the session DM room after a permission grant, the same +// channel create/delete/archive use. +async function receivedRealmsListUpdated( + matrixClient: MatrixClient, + roomId: string, +): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + let messages = await matrixClient.roomMessages(roomId); + let found = messages.some((event: MatrixEvent) => { + let { content } = event; + if (!isRealmServerEventContent(content)) { + return false; + } + try { + return ( + (JSON.parse(content.body) as { eventType?: string }).eventType === + REALMS_LIST_UPDATED_EVENT_TYPE + ); + } catch { + return false; + } + }); + if (found) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} module(`realm-endpoints/${basename(import.meta.filename)}`, function () { module('Realm-specific Endpoints | _permissions', function () { @@ -34,6 +86,125 @@ module(`realm-endpoints/${basename(import.meta.filename)}`, function () { dbAdapter = args.dbAdapter; } + // A permission grant must reach a signed-in user's running session without + // a reload. `patchRealmPermissions` pushes `realms-list-updated` into each + // affected user's session DM room; the host re-runs `_realm-auth` on it. + // Grant a user who has an established session room and assert the push + // lands there. + module('permission grant notifies the grantee', function (hooks) { + setupPermissionedRealmCached(hooks, { + fixture: 'blank', + permissions: { + mary: ['read', 'write', 'realm-owner'], + }, + onRealmSetup, + }); + + let matrixClient: MatrixClient; + let sessionRoom: string; + // The notifier is the realm's own matrix client. In production that is + // the single realm-server account that also owns every session room + // (`_server-session` / `_realm-auth` mint them), so the notifier is + // always a member of the grantee's room. This test harness deliberately + // splits those accounts — each realm gets its own bot (`test_realm`) + // distinct from the realm-server account (`node-test_realm-server`) that + // creates session rooms — so an ordinary grantee's room would not have + // the realm bot in it, and delivery would (correctly) fall to the + // stale-room self-heal. Granting the realm's own bot reproduces the + // production invariant that matters: the notifier is a member of the + // grantee's session room. The stale-room path is covered as a unit in + // send-event-test.ts. + let granteeUserId = '@test_realm:localhost'; + + hooks.beforeEach(async function () { + matrixClient = new MatrixClient({ + matrixURL: realmServerTestMatrix.url, + username: 'test_realm', + seed: realmSecretSeed, + }); + await matrixClient.login(); + ({ sessionRoom } = await createRealmServerSession( + matrixClient, + request, + )); + let { joined_rooms: rooms } = await matrixClient.getJoinedRooms(); + if (!rooms.includes(sessionRoom)) { + await matrixClient.joinRoom(sessionRoom); + } + }); + + test('PATCH /_permissions pushes realms-list-updated to the granted user', async function (assert) { + 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: { [granteeUserId]: ['read', 'write'] }, + }, + }, + }); + + assert.strictEqual(response.status, 200, 'the grant succeeds'); + + let permissions = await fetchRealmPermissions(dbAdapter, testRealmURL); + assert.deepEqual( + permissions[granteeUserId], + ['read', 'write'], + 'the grant is written to realm_user_permissions', + ); + + assert.ok( + await receivedRealmsListUpdated(matrixClient, sessionRoom), + 'a realms-list-updated event reached the grantee’s session room', + ); + }); + + 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 + // best-effort no-op and must not fail the grant. + 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: { '@no-session-user:localhost': ['read'] }, + }, + }, + }); + + assert.strictEqual(response.status, 200, 'the grant still succeeds'); + let permissions = await fetchRealmPermissions(dbAdapter, testRealmURL); + assert.deepEqual( + permissions['@no-session-user:localhost'], + ['read'], + 'the grant is written despite no session room to notify', + ); + }); + }); + module('permissions requests', function (hooks) { setupPermissionedRealmCached(hooks, { fixture: 'blank', diff --git a/packages/realm-server/tests/send-event-test.ts b/packages/realm-server/tests/send-event-test.ts index 05d45e65290..981a37cecfe 100644 --- a/packages/realm-server/tests/send-event-test.ts +++ b/packages/realm-server/tests/send-event-test.ts @@ -3,7 +3,7 @@ const { module, test } = QUnit; import { basename } from 'path'; import type { DBAdapter } from '@cardstack/runtime-common'; import type { MatrixClient } from '@cardstack/runtime-common/matrix-client'; -import { createSendEvent } from '../handlers/send-event.ts'; +import { createSendEvent } from '@cardstack/runtime-common/send-event'; // Realm events are addressed to a user's session room. Callers treat the // notify as best-effort and catch its failures, so what matters here is that @@ -15,14 +15,26 @@ module(basename(import.meta.filename), function () { loggedIn?: boolean; loginFails?: boolean; sendFails?: boolean; + notInRoom?: boolean; } = {}, ) { let sent: { roomId: string; body: unknown }[] = []; + let executes: string[] = []; let logins = 0; let dbAdapter = { kind: 'pg', - execute: async () => - sessionRoomId === null ? [] : [{ session_room_id: sessionRoomId }], + execute: async (sql: string) => { + executes.push(sql); + // The stale-room self-heal issues `UPDATE ... SET session_room_id = + // NULL ... RETURNING id`; report one row cleared. Every other read is + // the session-room lookup. + if (/SET session_room_id = NULL/i.test(sql)) { + return [{ id: 'cleared-1' }]; + } + return sessionRoomId === null + ? [] + : [{ session_room_id: sessionRoomId }]; + }, } as unknown as DBAdapter; let matrixClient = { isLoggedIn: () => opts.loggedIn ?? true, @@ -33,6 +45,17 @@ module(basename(import.meta.filename), function () { } }, sendEvent: async (roomId: string, _type: string, body: unknown) => { + if (opts.notInRoom) { + // Shape of a real Synapse rejection when the sender was never a + // member of the room (a stale row from a prior room owner). + throw new Error( + `Unable to send room event 'm.room.message' to room ${roomId}: status 403 - ` + + JSON.stringify({ + errcode: 'M_FORBIDDEN', + error: `@sender:localhost not in room ${roomId}`, + }), + ); + } if (opts.sendFails) { throw new Error('homeserver rejected the event'); } @@ -42,6 +65,7 @@ module(basename(import.meta.filename), function () { return { sendEvent: createSendEvent({ matrixClient, dbAdapter }), sent, + executes, loginCount: () => logins, }; } @@ -95,6 +119,24 @@ module(basename(import.meta.filename), function () { ); }); + // A stale session-room row (the sender was never in the room, e.g. it was + // minted by a prior room owner) is not a caller-visible failure: the helper + // clears the row so the user's next auth re-mints one this account is in, + // and resolves as the best-effort no-op it always was. + test('a "not in room" send clears the stale session room instead of throwing', async function (assert) { + let { sendEvent, sent, executes } = fakeDeps('!stale-room:localhost', { + notInRoom: true, + }); + + await sendEvent('@mango:localhost', 'realms-list-updated'); + + assert.deepEqual(sent, [], 'the event was not considered delivered'); + assert.ok( + executes.some((sql) => /SET session_room_id = NULL/i.test(sql)), + 'the stale session-room row was cleared', + ); + }); + test('an event for a user with a session room is delivered to it', async function (assert) { let { sendEvent, sent } = fakeDeps('!room-abc:localhost'); diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index cd604b448de..956d50ff678 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -1453,6 +1453,7 @@ export * from './db-queries/realm-metadata-queries.ts'; export * from './db-queries/realm-permission-queries.ts'; export * from './db-queries/session-room-queries.ts'; export * from './db-queries/user-queries.ts'; +export * from './send-event.ts'; // From https://github.com/iliakan/detect-node export const isNode = diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 9c2b0792721..163874ecb9f 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -262,6 +262,8 @@ import { fetchSessionRoom, upsertSessionRoom, } from './db-queries/session-room-queries.ts'; +import { REALMS_LIST_UPDATED_EVENT_TYPE } from './matrix-constants.ts'; +import { createSendEvent } from './send-event.ts'; import { userExists } from './db-queries/user-queries.ts'; import { analyzeRealmPublishability, @@ -8043,9 +8045,38 @@ export class Realm { // permission PATCHes are admin-rare so the over-invalidation is // negligible). await this.clearRealmIndexCachesAndBroadcast(); + // Tell each affected user their accessible-realm set changed so a running + // 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)); return await this.getRealmPermissions(request, requestContext); } + // Notify each affected user that their set of accessible realms changed, so + // a running session re-derives it from `_realm-auth` without a reload. + // Delivered into the user's session DM room via the shared `sendEvent` + // helper, which no-ops when the user has no session room and self-heals a + // stale one. Best-effort per user: a delivery failure must never roll back + // the grant that already committed, so one user's error is logged and the + // rest still run. + private async notifyRealmsListUpdated(users: string[]): Promise { + let sendEvent = createSendEvent({ + matrixClient: this.#matrixClient, + dbAdapter: this.#dbAdapter, + }); + for (let user of users) { + try { + await sendEvent(user, REALMS_LIST_UPDATED_EVENT_TYPE); + } catch (e) { + this.#log.error( + `failed to notify ${user} that their realms list changed`, + e, + ); + } + } + } + private async getLastPublishedAt(): Promise< string | Record | null > { diff --git a/packages/runtime-common/send-event.ts b/packages/runtime-common/send-event.ts new file mode 100644 index 00000000000..e81ce1ff02d --- /dev/null +++ b/packages/runtime-common/send-event.ts @@ -0,0 +1,121 @@ +import type { DBAdapter } from './db.ts'; +import { logger } from './log.ts'; +import type { MatrixClient } from './matrix-client.ts'; +import { APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE } from './matrix-constants.ts'; +import { + fetchSessionRoom, + clearSessionRoom, +} from './db-queries/session-room-queries.ts'; + +const log = logger('matrix:send-event'); + +export type SendEventDeps = { + matrixClient: MatrixClient; + dbAdapter: DBAdapter; +}; + +export type SendEvent = ( + user: string, + eventType: string, + data?: Record, +) => Promise; + +// Parse the JSON body a failed `matrixClient.sendEvent` carries on its Error +// message (the client stringifies `status - `), so callers can +// branch on the homeserver's errcode instead of substring-matching the whole +// message. +export function parseMatrixSendEventError(error: unknown): { + status?: number; + errcode?: string; + error?: string; +} | null { + if (!(error instanceof Error)) { + return null; + } + + let match = error.message.match(/status (\d+) - (\{.*\})$/); + if (!match) { + return null; + } + + let [, status, body] = match; + try { + return { + status: Number(status), + ...(JSON.parse(body) as { errcode?: string; error?: string }), + }; + } catch (_err) { + return { status: Number(status) }; + } +} + +// A send fails this way when the stored session room predates the current +// sender account (e.g. a room minted by a different bot before session-room +// creation consolidated onto one account). The row is stale: the sender was +// never a member. Callers clear it so the user's next auth mints a fresh room +// the current account is in. +export function isRealmServerNotInRoomError( + error: unknown, + roomId: string, +): boolean { + let details = parseMatrixSendEventError(error); + return Boolean( + details?.status === 403 && + details?.errcode === 'M_FORBIDDEN' && + details?.error?.includes(`not in room ${roomId}`), + ); +} + +// Build a best-effort "notify one user" sender addressed to that user's session +// DM room. Used by every server-side path that pokes a single running session +// (realm-permission grants, the grafana upsert handler, and so on) so the room +// lookup, login, message shape, and stale-room recovery live in one place. +export function createSendEvent({ + matrixClient, + dbAdapter, +}: SendEventDeps): SendEvent { + return async function sendEvent(user, eventType, data) { + // The room lookup runs before any Matrix call: it is the step that can + // make this a no-op, and it is a local database read, so a user with + // nothing to receive never depends on the homeserver being reachable. + let roomId = await fetchSessionRoom(dbAdapter, user); + if (!roomId) { + // No session room means nowhere to deliver to. Usually the user has + // never established one, which is ordinary for a realm created by the + // CLI, by an admin, or by a test fixture. `clearSessionRoom` also nulls + // the column for a live session whose DM the realm server has left, + // until that session mints a fresh room on its next `_server-session` + // or realm auth — inside that window a notify the user would have + // received is dropped here, at a level nothing surfaces, so an absent + // row is not proof that nobody is listening. Either way there is + // nothing addressable, and callers treat the notify as best-effort. + log.debug( + `skipping ${eventType} for ${user}: no session room to deliver to`, + ); + return; + } + + if (!matrixClient.isLoggedIn()) { + await matrixClient.login(); + } + + try { + await matrixClient.sendEvent(roomId, 'm.room.message', { + body: JSON.stringify({ eventType, data }), + msgtype: APP_BOXEL_REALM_SERVER_EVENT_MSGTYPE, + }); + } catch (e) { + if (isRealmServerNotInRoomError(e, roomId)) { + // Stale room: the sender was never a member. Clear it so the user's + // next auth re-mints one this account is in, and treat this notify as + // the best-effort no-op it always was rather than surfacing a throw. + let cleared = await clearSessionRoom(dbAdapter, user, roomId); + log.warn( + `skipping ${eventType} for ${user}: stale session room ${roomId} (sender not a member); cleared=${cleared}`, + ); + return; + } + throw e; + } + }; +}