From 58115bb8f62ae93216e002a5fe4a02ab8c3cfd0c Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 12 Oct 2026 19:25:40 -0400 Subject: [PATCH 1/7] Show only whitelisted public realms on the dashboard, and let users choose more catalogs The dashboard's Your Workspaces list comes from `_realm-auth`, which listed every world-readable realm, so a new account saw every public realm on the server as one of its workspaces. - fetchUserPermissions defaults to the user's own permission rows; includePublicRealms adds the `'*'`-readable realms. Every caller that authorizes or renders on a user's behalf opts in. - POST /_realm-auth likewise lists only the caller's own realms unless asked with ?include-public=true. boxel-cli, the host's login token fetch and the load harness opt in. - The Catalogs section shows the realms flagged realm_metadata.show_as_catalog. Operators set the flag with POST /_grafana-set-realm-catalog (public realms only), surfaced on the Grafana Realms dashboard. /_catalog-realms selects by the flag in SQL with a short cache, so a change reaches every instance. - Users add public realms to their own Catalogs section from a gear beside the heading. GET /_public-realms lists the candidates from the index without mounting them; picks persist in app.boxel.catalog-realms account data. The Boxel catalog always leads the section. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWtpTuSscLnbf84a2c96R9 --- .claude/skills/indexing-diagnostics/SKILL.md | 2 +- packages/ai-assistant-evals/realm-api.ts | 2 +- packages/boxel-cli/src/commands/realm/list.ts | 2 +- packages/boxel-cli/src/lib/auth.ts | 4 +- .../operator-mode/workspace-chooser/index.gts | 87 +++--- .../workspace-chooser/manage-catalogs.gts | 283 ++++++++++++++++++ packages/host/app/services/matrix-service.ts | 54 +++- packages/host/app/services/realm-server.ts | 94 +++++- .../acceptance/workspace-chooser-test.gts | 127 +++++++- packages/host/tests/helpers/index.gts | 1 + packages/host/tests/helpers/mock-matrix.ts | 2 + .../host/tests/helpers/mock-matrix/_client.ts | 8 + .../tests/helpers/realm-server-mock/index.ts | 17 ++ .../tests/helpers/realm-server-mock/routes.ts | 26 +- .../tests/helpers/realm-server-mock/types.ts | 5 + .../matrix-service-boot-assembly-test.ts | 26 ++ .../workspace-chooser-catalog-order-test.ts | 28 ++ .../dashboards/boxel-status/realms.json | 181 ++++++++++- .../handlers/handle-download-realm.ts | 1 + .../handlers/handle-fetch-catalog-realms.ts | 27 +- .../handlers/handle-fetch-public-realms.ts | 31 ++ .../handlers/handle-publish-realm.ts | 1 + .../handlers/handle-realm-auth.ts | 9 +- .../handlers/handle-set-realm-catalog.ts | 107 +++++++ .../handlers/handle-skill-validation.ts | 5 +- .../middleware/multi-realm-authorization.ts | 1 + packages/realm-server/routes.ts | 11 + .../scripts/load-harness/lib/auth.ts | 2 +- packages/realm-server/tests/queries-test.ts | 52 ++++ .../realm-server/tests/realm-auth-test.ts | 68 ++++- .../server-endpoints/user-and-catalog-test.ts | 179 ++++++++++- .../db-queries/realm-permission-queries.ts | 79 ++++- packages/runtime-common/definition-lookup.ts | 5 +- packages/runtime-common/matrix-constants.ts | 3 + .../runtime-common/run-command-request.ts | 1 + packages/runtime-common/tasks/capture.ts | 1 + packages/runtime-common/tasks/indexer.ts | 10 +- .../runtime-common/tasks/prerender-html.ts | 5 +- 38 files changed, 1449 insertions(+), 98 deletions(-) create mode 100644 packages/host/app/components/operator-mode/workspace-chooser/manage-catalogs.gts create mode 100644 packages/host/tests/unit/workspace-chooser-catalog-order-test.ts create mode 100644 packages/realm-server/handlers/handle-fetch-public-realms.ts create mode 100644 packages/realm-server/handlers/handle-set-realm-catalog.ts diff --git a/.claude/skills/indexing-diagnostics/SKILL.md b/.claude/skills/indexing-diagnostics/SKILL.md index 1f26b4c448b..34d9c6b4538 100644 --- a/.claude/skills/indexing-diagnostics/SKILL.md +++ b/.claude/skills/indexing-diagnostics/SKILL.md @@ -2743,7 +2743,7 @@ The per-realm reindex endpoints (`POST _reindex`, `POST _full-rein -d '{ "user": "@user:server", "realms": ["/"] }' ``` - The response carries a map of `{ : }`. **That** is the token you pass on `Authorization` when calling the reindex endpoint in the next section. It's _not_ the token the prerender tab uses — that one comes from the `prerenderer-reproduce` log. + The response carries a map of `{ : }`, covering the realms the user has a permission row of their own for. Add `?include-public=true` to the URL to also get tokens for realms only world-readable to them. **That** is the token you pass on `Authorization` when calling the reindex endpoint in the next section. It's _not_ the token the prerender tab uses — that one comes from the `prerenderer-reproduce` log. Three different JWTs float around in this area, so always be explicit about which one you mean: diff --git a/packages/ai-assistant-evals/realm-api.ts b/packages/ai-assistant-evals/realm-api.ts index 339e7e425e9..3d33409a7ed 100644 --- a/packages/ai-assistant-evals/realm-api.ts +++ b/packages/ai-assistant-evals/realm-api.ts @@ -75,7 +75,7 @@ export class RealmResponseError extends Error { // One matrix user talking to any number of realms. Realm sessions are minted // on first use per realm with the realm's own `_session` endpoint, which is // the path that honours a realm's `users` permission row (the realm server's -// `_realm-auth` only lists a user's own realms and the public ones). +// `_realm-auth` only lists a user's own realms, and the public ones on request). export class RealmClient { #accessToken: string; #userId: string; diff --git a/packages/boxel-cli/src/commands/realm/list.ts b/packages/boxel-cli/src/commands/realm/list.ts index bae81a8b2dd..f79afba5b03 100644 --- a/packages/boxel-cli/src/commands/realm/list.ts +++ b/packages/boxel-cli/src/commands/realm/list.ts @@ -66,7 +66,7 @@ export async function listRealms( let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); let response = await pm.authedRealmServerFetch( - `${realmServerUrl}/_realm-auth`, + `${realmServerUrl}/_realm-auth?include-public=true`, { method: 'POST', headers: { diff --git a/packages/boxel-cli/src/lib/auth.ts b/packages/boxel-cli/src/lib/auth.ts index d66d1ac6e5e..4c774a00d0e 100644 --- a/packages/boxel-cli/src/lib/auth.ts +++ b/packages/boxel-cli/src/lib/auth.ts @@ -292,7 +292,9 @@ export async function getRealmTokens( realmServerUrl: string, serverToken: string, ): Promise { - let url = `${realmServerUrl.replace(/\/$/, '')}/_realm-auth`; + // `include-public=true`: a token for every realm the user may read, public + // ones included, since authed realm fetches resolve their token from this. + let url = `${realmServerUrl.replace(/\/$/, '')}/_realm-auth?include-public=true`; let response = await fetch(url, { method: 'POST', diff --git a/packages/host/app/components/operator-mode/workspace-chooser/index.gts b/packages/host/app/components/operator-mode/workspace-chooser/index.gts index 86196a5e81a..5e499bef166 100644 --- a/packages/host/app/components/operator-mode/workspace-chooser/index.gts +++ b/packages/host/app/components/operator-mode/workspace-chooser/index.gts @@ -25,15 +25,30 @@ import type { Icon } from '@cardstack/boxel-ui/icons'; import { logger, ri } from '@cardstack/runtime-common'; import config from '@cardstack/host/config/environment'; +import { catalogRealmURL } from '@cardstack/host/lib/utils'; import type MatrixService from '@cardstack/host/services/matrix-service'; import type RealmService from '@cardstack/host/services/realm'; import type RealmServerService from '@cardstack/host/services/realm-server'; import AddWorkspace from './add-workspace'; import ArchivedWorkspace from './archived-workspace'; +import ManageCatalogs from './manage-catalogs'; import Workspace from './workspace'; import WorkspaceLoadingIndicator from './workspace-loading-indicator'; +// Moves `first` to the front of `urls` when it's there, keeping the rest in +// order. +export function leadWith( + urls: T[], + first: string | null, +): T[] { + let index = first ? urls.indexOf(first as T) : -1; + if (index <= 0) { + return urls; + } + return [urls[index], ...urls.slice(0, index), ...urls.slice(index + 1)]; +} + interface SortOption { label: string; icon: Icon; @@ -152,13 +167,6 @@ export default class WorkspaceChooser extends Component { return this.selectedSortOption.value; } - private get displayCatalogWorkspaces() { - return ( - this.realmServer.displayedCatalogRealmIdentifiers && - this.realmServer.displayedCatalogRealmIdentifiers.length > 0 - ); - } - private get communityRealmIdentifiers() { let realmURLs = this.realmServer.displayedCatalogRealmIdentifiers ?? []; if (config.environment !== 'production') { @@ -212,9 +220,13 @@ export default class WorkspaceChooser extends Component { return this.filterByHosted(this.realmServer.userRealmIdentifiers); } + // Newest first, except the Boxel catalog, which always leads the section. private get filteredCatalogRealmIdentifiers() { return this.filterByHosted( - this.sortByCreatedAtDesc(this.communityRealmIdentifiers), + leadWith( + this.sortByCreatedAtDesc(this.communityRealmIdentifiers), + catalogRealmURL, + ), ); } @@ -386,7 +398,7 @@ export default class WorkspaceChooser extends Component { } private get renderedCatalogCount() { - if (!this.displayCatalogWorkspaces || this.catalogEmptyMessage) { + if (this.catalogEmptyMessage) { return 0; } return this.filteredCatalogRealmIdentifiers.length; @@ -695,36 +707,35 @@ export default class WorkspaceChooser extends Component { {{/if}} - {{#if this.displayCatalogWorkspaces}} -
-
- - Catalogs -
- {{#if this.catalogEmptyMessage}} - {{this.catalogEmptyMessage}} - {{else}} -
- {{#each - this.filteredCatalogRealmIdentifiers - as |realmIdentifier i| - }} - {{#let (add this.catalogNavBase i) as |navIndex|}} - - {{/let}} - {{/each}} -
- {{/if}} +
+
+ + Catalogs +
- {{/if}} + {{#if this.catalogEmptyMessage}} + {{this.catalogEmptyMessage}} + {{else}} +
+ {{#each + this.filteredCatalogRealmIdentifiers + as |realmIdentifier i| + }} + {{#let (add this.catalogNavBase i) as |navIndex|}} + + {{/let}} + {{/each}} +
+ {{/if}} +