Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 158 additions & 4 deletions packages/host/app/services/matrix-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enable provisioning for self-hosted local deployments

When a self-hosted realm server does not set REALM_SENTRY_ENVIRONMENT, serve-index.ts retains the build default hostedEnvironment: 'local', so this condition disables provisioning there as well as in tests. Consequently, directly provisioned Matrix accounts in ordinary local/self-hosted installations still boot without the personal workspace this change is intended to create. Use an explicit test-mode signal or feature flag rather than treating every local deployment as a test.

Useful? React with 👍 / 👎.


// 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();
});
Comment on lines +970 to +972

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel provisioning when the session ends

If the user logs out or switches accounts while the profile lookup or realm creation is pending, this service-level task survives logout()/resetState(). It can then resume against the replacement Matrix client: the initial username check came from account A, while createPersonalRealmForUser() authenticates and appends account data using account B, potentially creating B's workspace with A's display name or mutating B's realm list. Cancel the task at the session boundary or verify that the initiating user is still current before creating and appending.

Useful? React with 👍 / 👎.


// 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<void> {
// 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(
Expand Down Expand Up @@ -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
Expand All @@ -1160,22 +1257,23 @@ 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');
userRealmURLs =
await this.realmServer.fetchUserRealmsFromTrustedServers(
trustedServers,
);
} else {
}
if (!useTrustedServers) {
this.bootedFromLegacyRealmsList = true;
if (isTesting())
console.warn('[start-phase] getAccountData(realms-legacy)');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
109 changes: 109 additions & 0 deletions packages/host/tests/integration/matrix-service-boot-assembly-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
},
);
Loading
Loading