-
Notifications
You must be signed in to change notification settings - Fork 12
Deliver realm grants to running sessions; boot permissioned accounts with a complete workspace list #6088
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Deliver realm grants to running sessions; boot permissioned accounts with a complete workspace list #6088
Changes from all commits
9606f81
25f9e04
560c033
7c96e5c
ce22fc9
c92577a
cd7f2d2
cd44bdf
86651fd
2a4e8fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
| }); | ||
|
Comment on lines
+970
to
+972
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the user logs out or switches accounts while the profile lookup or realm creation is pending, this service-level task survives 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( | ||
|
|
@@ -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,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)'); | ||
|
|
@@ -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) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a self-hosted realm server does not set
REALM_SENTRY_ENVIRONMENT,serve-index.tsretains the build defaulthostedEnvironment: '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 everylocaldeployment as a test.Useful? React with 👍 / 👎.