diff --git a/.changeset/26838-launcher-hidden-tabs.md b/.changeset/26838-launcher-hidden-tabs.md
new file mode 100644
index 00000000000..b8706d0f3aa
--- /dev/null
+++ b/.changeset/26838-launcher-hidden-tabs.md
@@ -0,0 +1,21 @@
+---
+'@object-ui/app-shell': patch
+'@object-ui/console': patch
+---
+
+fix(app-shell): Home, the app switcher and the `app:launcher` block no longer offer an app the user's permission sets hide
+
+Clause-②: no
+Metadata-protocol: none
+
+A permission set's `tabPermissions: { APP: 'hidden' }` hides an app from the member's launcher. The server applies that hide in one place, `GET /api/v1/me/apps`, and the console never read it: Home's tiles, the app switcher's menu and the `app:launcher` page block all listed `GET /api/v1/meta/app`, which still serves the hidden app because a hidden app keeps opening from a direct URL. So the member saw, and could open, the tile their admin had hidden.
+
+What changes:
+
+- The console reads `GET /api/v1/me/apps` once per user, beside its `/auth/me/permissions` read, and every launcher surface narrows its `/meta/app` list by that answer's app names. The tiles keep the labels, icons and publish state `/meta/app` serves.
+- An app the answer was not asked about is not hidden by it: an app that is installed or published after the read stays on the launcher, and the console asks again.
+- Home waits for the first answer, as it waits for the app list, so a hidden tile is never drawn and then removed.
+- A deployment without the security plugin answers `404` there; the launcher then lists every app the user can open, as before. Any other failure does the same and says so once on the browser console.
+- A hidden app still opens from its URL, and the switcher still names it while the user is in it. `default_off` is not a hide and changes nothing.
+
+What to do: nothing. A permission set that already hides an app now hides it on Home, in the switcher and in launcher blocks.
diff --git a/ui/apps/console/src/AppContent.tsx b/ui/apps/console/src/AppContent.tsx
index 1e27d3ee523..be884c6ab79 100644
--- a/ui/apps/console/src/AppContent.tsx
+++ b/ui/apps/console/src/AppContent.tsx
@@ -11,7 +11,7 @@
import { lazy, Suspense, useMemo, type ReactNode } from 'react';
import { Route, useParams, useLocation, Navigate } from 'react-router-dom';
-import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LoadingScreen, useAdapter } from '@object-ui/app-shell';
+import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LauncherAppsProvider, LoadingScreen, useAdapter } from '@object-ui/app-shell';
import { AdapterCtx } from '@object-ui/react';
import { MePermissionsProvider, ServerReadScope } from '@object-ui/permissions';
import { createAuthenticatedFetch } from '@object-ui/auth';
@@ -406,7 +406,13 @@ export function ConsolePermissionsScope({ children }: { children: ReactNode }) {
// error + retry affordance all along.
errorFallback={(err, retry) => }
>
- {children}
+ {/* objectstack#26838 — the apps a launcher offers (Home's tiles, the app
+ switcher, the `app:launcher` block) are the `/me/apps` answer, which
+ drops an app the user's permission sets hide. One read for Home and
+ every app, so this common ancestor holds it, beside the answer above. */}
+
+ {children}
+
);
}
diff --git a/ui/packages/app-shell/src/chrome/CommandPalette.launcherHiddenApps-26838.test.tsx b/ui/packages/app-shell/src/chrome/CommandPalette.launcherHiddenApps-26838.test.tsx
new file mode 100644
index 00000000000..055e586aa45
--- /dev/null
+++ b/ui/packages/app-shell/src/chrome/CommandPalette.launcherHiddenApps-26838.test.tsx
@@ -0,0 +1,103 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#26838 — the ⌘K palette's "Switch app" group offers no app the
+ * user's permission sets hide (`tabPermissions: hidden`): it is narrowed by
+ * the same `GET /me/apps` answer as Home's tiles and the app switcher. The
+ * palette keeps its own rule otherwise — every active app, a `hidden` one
+ * (the avatar-menu kind) included.
+ *
+ * Under the real `LauncherAppsProvider` fed a `/me/apps` double, the real
+ * `CommandPaletteProvider` opened by its `?palette=1` deep link, and a real
+ * `I18nProvider` in `en` — the `CommandPalette.navMatch-11812` harness.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { render, waitFor, cleanup } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { createI18n, I18nProvider } from '@object-ui/i18n';
+
+const state = vi.hoisted(() => ({ apps: [] as Array> }));
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }),
+}));
+// The provider reads the app list from the metadata cache; the rest of the
+// cache is the real (empty) one the palette's other groups read.
+vi.mock('../providers/MetadataProvider', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useMetadata: () => ({ ...actual.useMetadata(), apps: state.apps, loading: false }),
+ };
+});
+
+import { CommandPalette } from './CommandPalette';
+import { CommandPaletteProvider } from '../context/CommandPaletteProvider';
+import { LauncherAppsProvider } from '../hooks/useLauncherApps';
+
+const SHOWCASE = { name: 'showcase_app', label: 'Showcase', navigation: [] };
+const CRM = { name: 'crm_app', label: 'CRM', navigation: [] };
+const OPS = { name: 'ops_app', label: 'Ops', navigation: [] };
+const ACCOUNT = { name: 'account', label: 'Account', hidden: true, navigation: [] };
+
+/** A `/me/apps` double: the listed names, or a bare status. */
+function meApps(answer: string[] | number) {
+ return vi.fn(async (_url: unknown) =>
+ typeof answer === 'number'
+ ? new Response('{}', { status: answer })
+ : new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }),
+ );
+}
+
+function mount(fetcher: ReturnType) {
+ render(
+
+
+
+
+ {}}
+ dataSource={{ find: vi.fn(async () => ({ data: [] })), searchAll: vi.fn(async () => ({ hits: [] })) }}
+ />
+
+
+
+ ,
+ );
+ if (!document.querySelector('[cmdk-input]')) throw new Error('the palette did not open');
+}
+
+/** The visible text of every app item cmdk rendered. */
+function shownApps(): string[] {
+ return Array.from(document.querySelectorAll('[cmdk-item][data-value^="app "]')).map((el) =>
+ (el.textContent ?? '').replace(/Current$/, '').trim(),
+ );
+}
+
+beforeEach(() => {
+ state.apps = [SHOWCASE, CRM, OPS, ACCOUNT];
+});
+
+afterEach(() => cleanup());
+
+describe('objectstack#26838 — the palette offers no app a permission set hides', () => {
+ it('an app /me/apps does not list is not a "Switch app" item', async () => {
+ const fetcher = meApps(['showcase_app', 'crm_app', 'account']);
+ mount(fetcher);
+ await waitFor(() => expect(shownApps()).toEqual(['Showcase', 'CRM', 'Account']));
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it('CONTROL — a 404 (no security plugin) offers every active app, as before', async () => {
+ const fetcher = meApps(404);
+ mount(fetcher);
+ await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(shownApps()).toEqual(['Showcase', 'CRM', 'Ops', 'Account']));
+ });
+});
diff --git a/ui/packages/app-shell/src/chrome/CommandPalette.tsx b/ui/packages/app-shell/src/chrome/CommandPalette.tsx
index 97fee375502..aa28ef52f33 100644
--- a/ui/packages/app-shell/src/chrome/CommandPalette.tsx
+++ b/ui/packages/app-shell/src/chrome/CommandPalette.tsx
@@ -73,6 +73,7 @@ import { fetchPackages, type PkgEntry } from '../views/studio-design/packages-io
import { isPackageLessItem, studioOrgScopePath } from '../views/studio-design/studioScope.js';
import { DESIGNER_SURFACE_PARAM, formatSurfaceParam } from '../views/metadata-admin/nav-selection.js';
import { flattenNavigation, useAppReportEntries } from '../views/appReportIndex.js';
+import { useServedApps } from '../hooks/useLauncherApps.js';
/** The palette of the active app: the frame `ConsoleLayout` draws for `/apps/APP`. */
interface AppCommandPaletteProps {
@@ -235,7 +236,10 @@ function CommandPaletteBody(props: CommandPaletteProps) {
const appReports = useAppReportEntries(activeApp, open && !inStudio).entries.filter(
(entry) => !navReportNames.has(entry.name) && matches(entry.label, entry.name),
);
- const switchableApps = apps.filter((a) => a.active !== false);
+ // Every active app, `hidden` ones included, minus an app the user's
+ // permission sets hide: the palette offers none that Home's tiles and the
+ // switcher withhold for that reason (objectstack#26838).
+ const switchableApps = useServedApps(apps.filter((a) => a.active !== false));
const appItems =
switchableApps.length > 1
? switchableApps.filter((app) => matches(resolveKeyedI18nLabel(app.label, t), app.name))
diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx
index 3d610b8dc92..dfd9bb29c91 100644
--- a/ui/packages/app-shell/src/console/AppContent.tsx
+++ b/ui/packages/app-shell/src/console/AppContent.tsx
@@ -33,6 +33,7 @@ import {
import { buildExpressionUser } from '../providers/expressionUser.js';
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js';
import { useHomePath } from '../hooks/useHomePath.js';
+import { useLauncherApps } from '../hooks/useLauncherApps.js';
import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
@@ -336,8 +337,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// ⛔ `hidden` only — never `_unpublished` (objectstack#6955 / objectstack-ai/objectstack#4829 A1). The
// publish gate is enforced server-side; duplicating it here would strand a
// builder on their own unpublished app.
- const activeApps = apps.filter((a: any) => a.active !== false);
- const launcherApps = activeApps.filter((a: any) => a.hidden !== true);
+ //
+ // objectstack#26838 — and, like Home's tiles and the switcher, never on an
+ // app the user's permission sets hide (`tabPermissions: hidden`, the
+ // `/me/apps` answer); the route lookup below still finds such an app.
+ const { apps: launcherApps } = useLauncherApps(apps);
// ADR-0048 (A) — the route segment is the package id; resolve by it,
// falling back to the app name (legacy/alias URL).
// Built-in pseudo-routes under /apps/* that are NOT metadata apps (create-app,
diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.defaultLandingHiddenApp-26838.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.defaultLandingHiddenApp-26838.test.tsx
new file mode 100644
index 00000000000..60dcb90c7bf
--- /dev/null
+++ b/ui/packages/app-shell/src/console/__tests__/AppContent.defaultLandingHiddenApp-26838.test.tsx
@@ -0,0 +1,215 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#26838 — when a URL names no app the shell can resolve (a
+ * pseudo-route such as `/apps/ghost/system/marketplace`), `AppContent` falls
+ * back to the default launcher app. That fallback never lands on an app the
+ * user's permission sets hide (`tabPermissions: hidden`): its list is the one
+ * Home's tiles and the switcher offer, narrowed by the `GET /me/apps` answer.
+ *
+ * Under the real `LauncherAppsProvider` fed a `/me/apps` double; the routing
+ * harness (stubs, `ConsoleLayout` probe echoing `activeAppName`) is
+ * `AppContent.pseudoRouteSegments.test.tsx`'s.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+import {
+ MemoryRouter,
+ Routes,
+ Route,
+ Navigate,
+ useParams,
+} from 'react-router-dom';
+
+// ---------------------------------------------------------------------------
+// Mocks — everything that takes part in the routing decision (AppContent's
+// guards, its nested , react-router's matching) stays real.
+// ---------------------------------------------------------------------------
+
+// AGENTS.md §测试纪律 — the lazy page modules are stubbed so no unbounded
+// `import()` races a bounded `findBy` window.
+vi.mock('../../views/metadata-admin', () => ({
+ MetadataDirectoryPage: () =>
,
+ EditAppPage: () => ,
+ DashboardDesignPage: () => ,
+}));
+
+// Pay the real designer barrel's load in the IMPORT phase, where no timeout
+// applies. `AppContent` reaches `CreateAppPage` only through
+// `React.lazy(() => import('@object-ui/plugin-designer'))`, so the factory
+// above -- which now awaits the real module -- first runs when that lazy
+// boundary resolves, i.e. INSIDE the `findByTestId` budget of the two cases
+// below that assert on `create-app-page`. Under a saturated transform pipeline
+// the barrel's graph does not fit in that budget and the file goes red on LOAD
+// rather than on behaviour (AGENTS.md, the flaky-test discipline: an unbounded
+// module load counted against a bounded window). The specifier is byte-
+// identical to the component's own, so ESM hands the lazy factory this
+// already-resolved module.
+import '@object-ui/plugin-designer';
+
+/**
+ * The probe that makes the bug visible: it reports WHICH app's shell rendered.
+ * The real `ConsoleLayout` drags in the whole console chrome (sidebar, header,
+ * chat dock) — none of it part of this question.
+ */
+vi.mock('../../layout/ConsoleLayout', () => ({
+ ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => (
+
;
+ },
+}));
+
+vi.mock('@object-ui/i18n', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useObjectTranslation: () => ({
+ t: (key: string, options?: Record) => String(options?.defaultValue ?? key),
+ }),
+ useObjectLabel: () => ({
+ objectLabel: ({ label }: { label?: string }) => label,
+ }),
+}));
+
+// A workspace admin: the zero-app cases below land on the "no apps configured"
+// empty state, which since objectui#4473 renders for admins only — a viewer who
+// cannot act on its CTAs is bounced to `/home` with the shell chrome instead of
+// being stranded there (`AppContent.inaccessibleAppStrand.test.tsx`). Which
+// BRANCH a segment reaches — the question this file measures — does not depend
+// on the viewer.
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ // A signed-in user: the `/me/apps` read is asked per user.
+ useAuth: () => ({
+ user: { id: 'u1' },
+ getAuthConfig: async () => ({ features: {} }),
+ activeOrganization: null,
+ }),
+ useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
+}));
+
+const dataSourceStub = {
+ onConnectionStateChange: () => () => {},
+ getConnectionState: () => 'connected',
+};
+vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAdapter: () => dataSourceStub,
+}));
+
+/**
+ * Two published apps, one of them the default. The default is what a broken
+ * `requestedAppMissing` falls back to, so `crm` is the app that must NEVER
+ * appear for a `/apps//…` URL.
+ */
+const APPS = [
+ { name: 'crm', label: 'CRM', isDefault: true, navigation: [] },
+ { name: 'sales', label: 'Sales', navigation: [] },
+];
+
+const refreshMetadata = vi.fn(async () => {});
+let metadataApps: unknown[] = APPS;
+vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useMetadata: () => ({
+ apps: metadataApps,
+ objects: [],
+ loading: false,
+ // `undefined` — no bucket preloading to await, so the shell is ready on
+ // first render (mirrors a host that ships metadata eagerly).
+ ensureType: undefined,
+ error: null,
+ refresh: refreshMetadata,
+ }),
+}));
+
+import { AppContent } from '../AppContent';
+import { LauncherAppsProvider } from '../../hooks/useLauncherApps';
+
+/** A `/me/apps` double: the listed names, or a bare status. */
+function meApps(answer: string[] | number) {
+ return vi.fn(async (_url: unknown) =>
+ typeof answer === 'number'
+ ? new Response('{}', { status: answer })
+ : new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }),
+ );
+}
+
+function renderAt(url: string, fetcher: ReturnType) {
+ render(
+
+
+
+ } />
+ home} />
+ } />
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ metadataApps = APPS;
+});
+
+afterEach(() => cleanup());
+
+describe('the default-app fallback skips an app a permission set hides (objectstack#26838)', () => {
+ it('the default app hidden by /me/apps: the fallback lands on the next launcher app', async () => {
+ const fetcher = meApps(['sales']);
+ renderAt('/apps/ghost/system/marketplace', fetcher);
+ expect(await screen.findByTestId('marketplace-page')).toBeInTheDocument();
+ await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
+ await waitFor(() =>
+ expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'sales'),
+ );
+ });
+
+ it('CONTROL — a 404 (no security plugin) keeps the declared default', async () => {
+ const fetcher = meApps(404);
+ renderAt('/apps/ghost/system/marketplace', fetcher);
+ expect(await screen.findByTestId('marketplace-page')).toBeInTheDocument();
+ await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
+ expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'crm');
+ });
+
+ it('a URL that names the hidden app still opens it', async () => {
+ renderAt('/apps/crm/system/marketplace', meApps(['sales']));
+ expect(await screen.findByTestId('marketplace-page')).toBeInTheDocument();
+ expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'crm');
+ });
+});
diff --git a/ui/packages/app-shell/src/console/home/HomePage.tsx b/ui/packages/app-shell/src/console/home/HomePage.tsx
index 56447d7d812..231e4b759b1 100644
--- a/ui/packages/app-shell/src/console/home/HomePage.tsx
+++ b/ui/packages/app-shell/src/console/home/HomePage.tsx
@@ -38,6 +38,7 @@ import { RecoveryPasswordReminder } from './RecoveryPasswordReminder.js';
import { HomeActionCenter, HomeContinue, HomeActivity, HomePendingDraftsRow, type RecentItemContext } from './HomeRail.js';
import { resolveI18nLabel } from '@objectstack/spec/ui';
import { useHomeInbox } from '../../hooks/useHomeInbox.js';
+import { useLauncherApps } from '../../hooks/useLauncherApps.js';
import { useNavigationContext } from '../../context/NavigationContext.js';
import {
appRouteSegment,
@@ -415,6 +416,12 @@ export function HomePage() {
});
const activeApps = filterActiveApps(apps);
+ // The apps the tiles and the System Settings link offer: the active list
+ // narrowed by the user's `/me/apps` answer, so an app a permission set hides
+ // draws no tile (objectstack#26838). The other readings below — the host
+ // app, a recent row's app label — keep the full list: a hidden app still
+ // opens, so a link into it or a label for it is still true.
+ const launcher = useLauncherApps(apps);
/**
* Which app hosts the app-independent pages this page links into — the
@@ -445,8 +452,8 @@ export function HomePage() {
* user can open, exactly where its tile used to be drawn.
*/
const setupPath = resolveSetupAppPath(activeApps);
- const setupApp = activeApps.find((app) => `/apps/${appRouteSegment(app)}` === setupPath);
- const launcherApps = setupApp ? activeApps.filter((app) => app !== setupApp) : activeApps;
+ const setupApp = launcher.apps.find((app) => `/apps/${appRouteSegment(app)}` === setupPath);
+ const launcherApps = setupApp ? launcher.apps.filter((app) => app !== setupApp) : launcher.apps;
// When each tile's app was last open — the recent-items store's newest entry
// inside it (objectui#12601; the reading is `lastOpenedByApp`'s).
const lastOpened = lastOpenedByApp(launcherApps, recentItems);
@@ -527,8 +534,9 @@ export function HomePage() {
// the verdict still in flight drops the admin entries and adds them back a
// moment later. `isResolved` is true the instant `isAdmin` is, so an admin the
// session already identifies waits nothing; this only holds the frame that
- // would otherwise be built on a guess.
- if (loading || !isAdminResolved) {
+ // would otherwise be built on a guess. The first `/me/apps` read joins it
+ // too, so a hidden app's tile is never drawn and then taken away.
+ if (loading || !isAdminResolved || launcher.pending) {
return (
{t('home.loading', { defaultValue: 'Loading…' })}
diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx
new file mode 100644
index 00000000000..3c23c9f11b8
--- /dev/null
+++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx
@@ -0,0 +1,228 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#26838 — Home's tiles are the apps the user's `GET /me/apps`
+ * answer lists, so an app a permission set hides (`tabPermissions: hidden`)
+ * draws no tile, while `GET /meta/app` still serves it (it opens from a URL).
+ *
+ * On the real `HomePage` and `HomeAppsStrip`, under the real
+ * `LauncherAppsProvider` fed a `/me/apps` double:
+ *
+ * - `/meta/app` = [setup, crm, showcase_app], `/me/apps` = [setup, crm]:
+ * no Showcase tile; Setup's System Settings link stays;
+ * - a set that hides Setup takes the System Settings link away too;
+ * - `404` (no security plugin): every tile, as before;
+ * - while the first read is out the page holds its loading line and draws
+ * no tile, so a hidden tile is never drawn and then taken away.
+ *
+ * The harness is `HomePage.greetingAndApps-12601.test.tsx`'s.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { render, screen, cleanup, act } from '@testing-library/react';
+
+const navigateMock = vi.fn();
+
+const state = vi.hoisted(() => ({
+ isAdmin: true,
+ canAuthorMetadata: true,
+ marketplaceEnabled: true,
+ pendingApprovalsCount: 0,
+ approvalsStatus: 'ready' as 'idle' | 'loading' | 'ready' | 'error',
+ apps: [] as Array>,
+ recentItems: [] as Array>,
+}));
+
+vi.mock('react-router-dom', async () => {
+ const { createElement } = await import('react');
+ return {
+ useNavigate: () => navigateMock,
+ // The tiles and the settings link are router `Link`s; this harness has no
+ // router, so the stand-in renders the anchor `Link` would, minus the basename.
+ Link: ({ to, ...rest }: { to: string } & Record) =>
+ createElement('a', { ...rest, href: to }),
+ };
+});
+
+vi.mock('@object-ui/i18n', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useObjectTranslation: () => ({
+ t: (key: string, opts?: { count?: number; time?: string }) =>
+ `«${key}»` +
+ (opts?.count === undefined ? '' : `[count=${opts.count}]`) +
+ (opts?.time === undefined ? '' : `[time=${opts.time}]`),
+ language: 'en',
+ }),
+ // Every labeller echoes the label, else the name: the tiles read `appLabel`,
+ // and the recent rail beside them labels the store's entries.
+ useObjectLabel: () => {
+ const label = (x: { name?: string; label?: string }) => String(x?.label || x?.name || '');
+ return {
+ appLabel: label,
+ objectLabel: label,
+ objectPluralLabel: label,
+ dashboardLabel: label,
+ pageLabel: label,
+ reportLabel: label,
+ viewLabel: (_object: string, _view: string, fallback?: string) => fallback ?? '',
+ };
+ },
+}));
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuth: () => ({ user: { id: 'u1', name: 'Dev Admin', email: 'dev.admin@example.com' } }),
+ useWorkspaceAdminStatus: () => ({ isAdmin: state.isAdmin, isResolved: true }),
+}));
+
+// One custom agent and no ask or build agent: AI is on here, so the build chip
+// keeps its "Build an app" form (objectstack#26614 gives the agent-less
+// deployment its own, `HomePage.noAgentsBuildChip-26614.test.tsx`), and no AI
+// CTA joins the greeting row, which holds the builder chips alone.
+vi.mock('@object-ui/plugin-chatbot/core', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useAgents: () => ({ agents: [{ name: 'custom_helper' }] }),
+ isAskAgent: () => false,
+ agentHasCapability: () => false,
+}));
+
+vi.mock('../../../providers/MetadataProvider', () => ({
+ useMetadata: () => ({ apps: state.apps, objects: [], dashboards: [], pages: [], reports: [], loading: false }),
+}));
+vi.mock('../../../context/NavigationContext', () => ({
+ useNavigationContext: () => ({ currentAppName: undefined }),
+}));
+vi.mock('../../../hooks/useCanAuthorMetadata', () => ({
+ useCanAuthorMetadata: () => state.canAuthorMetadata,
+ useCanReadPendingDrafts: () => true,
+}));
+// The page's one inbox seam: the approvals count AND whether it is an answer.
+vi.mock('../../../hooks/useHomeInbox', () => ({
+ useHomeInbox: () => ({
+ pendingApprovalsCount: state.pendingApprovalsCount,
+ // objectui#12602: the approvals preview, and whether each source is
+ // the caller's to read — the My-work section draws a column per source.
+ pendingApprovals: [],
+ pendingApprovalsReadable: true,
+ notificationsReadable: true,
+ pendingApprovalsStatus: state.approvalsStatus,
+ notifications: [],
+ unreadTopicCount: 0,
+ notificationsStatus: 'ready',
+ activity: { value: [], status: 'ready', readable: true },
+ }),
+}));
+vi.mock('../../../runtime-config', () => ({
+ getRuntimeConfig: () => ({ branding: { productName: 'ObjectStack' } }),
+ isMarketplaceEnabled: () => state.marketplaceEnabled,
+ isAiStudioEnabled: () => true,
+}));
+
+// The tiles' last-opened time reads the recent-items store.
+vi.mock('../../../hooks/useRecentItems', () => ({
+ useRecentItems: () => ({ recentItems: state.recentItems }),
+}));
+
+// --- surfaces these cases do not read ---------------------------------------
+vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites: [] }) }));
+vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
+vi.mock('../../../preview/usePendingDrafts', () => ({ usePendingDrafts: () => ({ count: 0 }) }));
+vi.mock('../../../preview/usePublishAllDrafts', () => ({
+ usePublishAllDrafts: () => ({ publishAll: async () => ({ ok: true }), publishing: false }),
+}));
+
+import { HomePage } from '../HomePage';
+import { LauncherAppsProvider } from '../../../hooks/useLauncherApps';
+
+const SETUP = { name: 'setup', label: 'Setup', icon: 'Settings', _packageId: 'com.objectstack.setup' };
+const CRM = { name: 'crm', label: 'CRM', icon: 'Building2' };
+const SHOWCASE = { name: 'showcase_app', label: 'Showcase', icon: 'Sparkles' };
+
+const ENDPOINT = '/me-apps-double';
+
+/** A `/me/apps` double: the listed names, or a bare status. */
+function meApps(answer: string[] | number) {
+ return vi.fn(async (_url: unknown) =>
+ typeof answer === 'number'
+ ? new Response('{}', { status: answer })
+ : new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }),
+ );
+}
+
+function renderHome(fetcher: unknown) {
+ return render(
+
+
+ ,
+ );
+}
+
+/** Every URL the page handed the global `fetch` — none is expected. */
+let fetchCalls: string[] = [];
+
+beforeEach(() => {
+ navigateMock.mockReset();
+ state.isAdmin = true;
+ state.canAuthorMetadata = true;
+ state.marketplaceEnabled = true;
+ state.pendingApprovalsCount = 0;
+ state.approvalsStatus = 'ready';
+ state.apps = [SETUP, CRM, SHOWCASE];
+ state.recentItems = [];
+ fetchCalls = [];
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: unknown) => {
+ fetchCalls.push(String(input));
+ return { ok: false, status: 404, json: async () => ({}) };
+ }),
+ );
+});
+
+afterEach(() => {
+ expect(fetchCalls).toEqual([]);
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('Home draws a tile only for an app /me/apps lists (objectstack#26838)', () => {
+ it('an app the member’s set hides draws no tile', async () => {
+ const fetcher = meApps(['setup', 'crm']);
+ renderHome(fetcher);
+ expect(await screen.findByTestId('app-tile-crm')).toBeInTheDocument();
+ expect(screen.queryByTestId('app-tile-showcase_app')).toBeNull();
+ expect(screen.getByTestId('home-system-settings-link')).toBeInTheDocument();
+ expect(screen.getByTestId('home-apps-count')).toHaveTextContent(/^1$/);
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it('a set that hides Setup takes the System Settings link away', async () => {
+ renderHome(meApps(['crm', 'showcase_app']));
+ expect(await screen.findByTestId('app-tile-showcase_app')).toBeInTheDocument();
+ expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument();
+ expect(screen.queryByTestId('home-system-settings-link')).toBeNull();
+ });
+
+ it('CONTROL — a 404 (no security plugin) draws every tile, as before', async () => {
+ renderHome(meApps(404));
+ expect(await screen.findByTestId('app-tile-showcase_app')).toBeInTheDocument();
+ expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument();
+ expect(screen.getByTestId('home-system-settings-link')).toBeInTheDocument();
+ });
+
+ it('holds the loading line, and no tile, while the first read is out', async () => {
+ let answer!: (res: Response) => void;
+ renderHome(vi.fn(() => new Promise((resolve) => (answer = resolve))));
+ expect(await screen.findByText('«home.loading»')).toBeInTheDocument();
+ expect(screen.queryByTestId('app-tile-showcase_app')).toBeNull();
+ expect(screen.queryByTestId('app-tile-crm')).toBeNull();
+
+ await act(async () => {
+ answer(new Response(JSON.stringify({ apps: [{ name: 'setup' }, { name: 'crm' }] }), { status: 200 }));
+ });
+ expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument();
+ expect(screen.queryByTestId('app-tile-showcase_app')).toBeNull();
+ });
+});
diff --git a/ui/packages/app-shell/src/hooks/__tests__/useLauncherApps-26838.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useLauncherApps-26838.test.tsx
new file mode 100644
index 00000000000..eb29971aea4
--- /dev/null
+++ b/ui/packages/app-shell/src/hooks/__tests__/useLauncherApps-26838.test.tsx
@@ -0,0 +1,186 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#26838 — `useLauncherApps` narrows a launcher's `/meta/app` list
+ * by the user's `GET /me/apps` answer, which drops an app a permission set
+ * hides (`tabPermissions: hidden`).
+ *
+ * What these cases pin, on the real `LauncherAppsProvider` fed a `/me/apps`
+ * double:
+ *
+ * - an app the answer did not list is not offered; the rest keep the
+ * `/meta/app` entries themselves;
+ * - no provider, a `404` and a failed read all narrow nothing — the last
+ * says so once, the `404` (no security plugin) does not;
+ * - `pending` holds only while the FIRST read is out;
+ * - an app the answer was never asked about passes, and its arrival asks
+ * again; two consumers share one read.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
+
+const state = vi.hoisted(() => ({
+ userId: 'u1' as string | null,
+ apps: [] as Array>,
+ loading: false,
+}));
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuth: () => ({ user: state.userId ? { id: state.userId } : null }),
+}));
+vi.mock('../../providers/MetadataProvider', () => ({
+ useMetadata: () => ({ apps: state.apps, loading: state.loading }),
+}));
+
+import { LauncherAppsProvider, useLauncherApps } from '../useLauncherApps';
+
+const SETUP = { name: 'setup', label: 'Setup' };
+const ACCOUNT = { name: 'account', label: 'Account', hidden: true };
+const CRM = { name: 'crm', label: 'CRM' };
+const SHOWCASE = { name: 'showcase_app', label: 'Showcase' };
+
+const ENDPOINT = '/me-apps-double';
+
+/** A `/me/apps` double answering `names`, or `status` with no body. */
+function meApps(answer: string[] | number) {
+ return vi.fn(async (_url: unknown) =>
+ typeof answer === 'number'
+ ? new Response('{}', { status: answer })
+ : new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }),
+ );
+}
+
+/** Prints what the hook answers: the offered names, and `pending`. */
+function Probe({ id = 'probe' }: { id?: string }) {
+ const { apps, pending } = useLauncherApps(state.apps);
+ return (
+
+ );
+}
+
+function renderWith(fetcher: typeof fetch, children: React.ReactNode = ) {
+ return render(
+
+ {children}
+ ,
+ );
+}
+
+beforeEach(() => {
+ state.userId = 'u1';
+ state.apps = [SETUP, ACCOUNT, CRM, SHOWCASE];
+ state.loading = false;
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('useLauncherApps narrows by /me/apps (objectstack#26838)', () => {
+ it('drops an app the /me/apps answer does not list, keeping the /meta/app entries', async () => {
+ const fetcher = meApps(['setup', 'account', 'crm']);
+ const seen = vi.fn<(apps: unknown[]) => void>();
+ function Entries() {
+ const { apps } = useLauncherApps(state.apps);
+ React.useEffect(() => seen(apps), [apps]);
+ return null;
+ }
+ renderWith(fetcher as unknown as typeof fetch, <>>);
+ await waitFor(() => expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm$/));
+ expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false');
+ // The entries are `/meta/app`'s own objects, not the answer's.
+ const entries = seen.mock.lastCall?.[0];
+ expect(entries).toEqual([SETUP, CRM]);
+ expect(entries?.[0]).toBe(SETUP);
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ expect(fetcher.mock.calls[0][0]).toBe(ENDPOINT);
+ });
+
+ it('outside a provider narrows nothing and is not pending', () => {
+ render();
+ expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm,showcase_app$/);
+ expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false');
+ });
+
+ it('a 404 (no security plugin) narrows nothing, silently, and is not asked again', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const fetcher = meApps(404);
+ const { rerender } = renderWith(fetcher as unknown as typeof fetch);
+ await waitFor(() => expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false'));
+ expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm,showcase_app$/);
+ expect(warn).not.toHaveBeenCalled();
+
+ state.apps = [...state.apps, { name: 'ops', label: 'Ops' }];
+ rerender(
+
+
+ ,
+ );
+ expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm,showcase_app,ops$/);
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it('a failed read narrows nothing and says so once', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ renderWith(meApps(500) as unknown as typeof fetch);
+ await waitFor(() => expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false'));
+ expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm,showcase_app$/);
+ expect(warn).toHaveBeenCalledTimes(1);
+ expect(String(warn.mock.calls[0][0])).toContain('/me/apps');
+ });
+
+ it('is pending only while the first read is out', async () => {
+ let answer!: (res: Response) => void;
+ const fetcher = vi.fn(() => new Promise((resolve) => (answer = resolve)));
+ renderWith(fetcher as unknown as typeof fetch);
+ await waitFor(() => expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'true'));
+ await act(async () => {
+ answer(new Response(JSON.stringify({ apps: [{ name: 'crm' }] }), { status: 200 }));
+ });
+ expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false');
+ expect(screen.getByTestId('probe')).toHaveTextContent(/^crm$/);
+ });
+
+ it('an app the answer was not asked about passes, and its arrival asks again', async () => {
+ const fetcher = vi
+ .fn()
+ .mockResolvedValueOnce(new Response(JSON.stringify({ apps: [{ name: 'crm' }] }), { status: 200 }))
+ .mockReturnValueOnce(new Promise(() => {}));
+ const { rerender } = renderWith(fetcher as unknown as typeof fetch);
+ await waitFor(() => expect(screen.getByTestId('probe')).toHaveTextContent(/^crm$/));
+
+ // Installed after the read: offered while the re-read is out, not pending.
+ state.apps = [...state.apps, { name: 'ops', label: 'Ops' }];
+ rerender(
+
+
+ ,
+ );
+ await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
+ expect(screen.getByTestId('probe')).toHaveTextContent(/^crm,ops$/);
+ expect(screen.getByTestId('probe')).toHaveAttribute('data-pending', 'false');
+ });
+
+ it('two consumers share one read', async () => {
+ const fetcher = meApps(['crm']);
+ renderWith(fetcher as unknown as typeof fetch, <>>);
+ await waitFor(() => expect(screen.getByTestId('a')).toHaveTextContent(/^crm$/));
+ expect(screen.getByTestId('b')).toHaveTextContent(/^crm$/);
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it('asks nothing without a signed-in user', () => {
+ state.userId = null;
+ const fetcher = meApps(['crm']);
+ renderWith(fetcher as unknown as typeof fetch);
+ expect(screen.getByTestId('probe')).toHaveTextContent(/^setup,crm,showcase_app$/);
+ expect(fetcher).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/packages/app-shell/src/hooks/index.ts b/ui/packages/app-shell/src/hooks/index.ts
index b74b6b7e178..9dd026b4fc1 100644
--- a/ui/packages/app-shell/src/hooks/index.ts
+++ b/ui/packages/app-shell/src/hooks/index.ts
@@ -63,6 +63,13 @@ export {
} from './useUrlOverlay.js';
export { useTenancyPosture } from './useTenancyPosture.js';
export { useHomePath } from './useHomePath.js';
+export {
+ LauncherAppsProvider,
+ useLauncherApps,
+ useServedApps,
+ type LauncherApps,
+ type LauncherAppsProviderProps,
+} from './useLauncherApps.js';
export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent.js';
export {
sanitizeChatMessagesForCache,
diff --git a/ui/packages/app-shell/src/hooks/useLauncherApps.tsx b/ui/packages/app-shell/src/hooks/useLauncherApps.tsx
new file mode 100644
index 00000000000..5f6988898a8
--- /dev/null
+++ b/ui/packages/app-shell/src/hooks/useLauncherApps.tsx
@@ -0,0 +1,226 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `useLauncherApps` — the apps a launcher surface offers, as the SERVER filters
+ * them for this user (objectstack#26838).
+ *
+ * `GET /api/v1/meta/app` serves every app the caller may open, routing
+ * included: a permission set's `tabPermissions: { : 'hidden' }` does not
+ * withhold an app there, and must not — a hidden app keeps opening from a
+ * direct URL (UI visibility, not access control). The hide is applied in one
+ * place, `GET /api/v1/me/apps`, which merges the caller's sets (most visible
+ * wins) and drops the hidden ones. Home read the first list and drew a tile
+ * for an app the member's set hides; the app switcher and the `app:launcher`
+ * page block read the same list and offered it too.
+ *
+ * So every launcher surface narrows its `/meta/app` list through this hook,
+ * and the hook through ONE read of `/me/apps` per user, held by
+ * {@link LauncherAppsProvider}. Three surfaces, one answer: they cannot
+ * disagree about which app is offered.
+ *
+ * What the answer narrows, and what it leaves:
+ *
+ * - The list keeps `/meta/app`'s entries — their labels, icons, package ids
+ * and publish state are that route's. `/me/apps` contributes NAMES only.
+ * - An app the answer was never asked about is not hidden by it. The provider
+ * records the active app names it asked with; an app that arrives later (an
+ * install, a publish) passes until the re-read it triggers lands, rather
+ * than vanishing from the launcher it was just installed into.
+ * - No provider mounted, a `404` (no security plugin serves the route), or a
+ * failed read: nothing narrows, and the surface shows the `/meta/app` list
+ * as it always did. That is the server's own posture — `/me/apps` fails open
+ * without the security plugin — and the same "cosmetic, never fail-closed"
+ * one the console's other personal reads take. A failure other than `404`
+ * says so once on the console.
+ * - `pending` is true only while the FIRST read is out. Home waits on it, so a
+ * hidden tile is never drawn and then taken away; a re-read keeps serving
+ * the answer it replaces.
+ *
+ * `default_off` is not read here: it is not a hide.
+ *
+ * @module
+ */
+
+import * as React from 'react';
+import { createContext, useContext, useEffect, useRef, useState } from 'react';
+import { createAuthenticatedFetch, useAuth } from '@object-ui/auth';
+import { useMetadata } from '../providers/MetadataProvider.js';
+import { filterActiveApps } from '../utils/appRoute.js';
+import { resolveRuntimeApiBase } from './storageUsageEndpoint.js';
+
+type LauncherAppLike = { name?: unknown } & Record;
+
+/** One `/me/apps` answer, and the app names it speaks for. */
+interface LauncherAnswer {
+ /** The app names `/me/apps` listed. */
+ served: ReadonlySet;
+ /** The active `/meta/app` names when the read went out. */
+ asked: ReadonlySet;
+}
+
+type LauncherAppsState =
+ | { status: 'idle' }
+ | { status: 'pending' }
+ | { status: 'ready'; answer: LauncherAnswer }
+ | { status: 'unavailable' };
+
+const LauncherAppsContext = createContext(null);
+
+/** The provider's state, and the user it belongs to. */
+interface HeldAnswer {
+ owner: string | null;
+ state: LauncherAppsState;
+}
+
+const NOTHING_HELD: HeldAnswer = { owner: null, state: { status: 'idle' } };
+
+/** A non-2xx answer, kept apart so `404` (route not served) reads as absence. */
+class LauncherAppsReadError extends Error {
+ constructor(readonly httpStatus: number) {
+ super(`GET /me/apps returned ${httpStatus}`);
+ this.name = 'LauncherAppsReadError';
+ }
+}
+
+async function readServedAppNames(url: string, doFetch: typeof fetch): Promise> {
+ const res = await doFetch(url, {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ credentials: 'include',
+ });
+ if (!res.ok) throw new LauncherAppsReadError(res.status);
+ const body = (await res.json()) as { apps?: unknown } | null;
+ if (!Array.isArray(body?.apps)) throw new Error('GET /me/apps answered without an `apps` array');
+ const names = new Set();
+ for (const app of body.apps as Array<{ name?: unknown } | null>) {
+ if (typeof app?.name === 'string') names.add(app.name);
+ }
+ return names;
+}
+
+function activeAppNames(apps: readonly LauncherAppLike[] | null | undefined): string[] {
+ const names = new Set();
+ for (const app of filterActiveApps(apps)) {
+ if (typeof app?.name === 'string') names.add(app.name);
+ }
+ return [...names].sort();
+}
+
+export interface LauncherAppsProviderProps {
+ children?: React.ReactNode;
+ /** Defaults to `{VITE_SERVER_URL}/api/v1/me/apps`. */
+ endpoint?: string;
+ /**
+ * Defaults to a fresh `createAuthenticatedFetch()` per read: the console's
+ * `/api/v1/*` lane carries a Bearer token and no session cookie.
+ */
+ fetcher?: typeof fetch;
+}
+
+/**
+ * Holds the signed-in user's `/me/apps` answer for every launcher surface
+ * below it. Mount it once, where Home and the apps share an ancestor — the
+ * console mounts it beside the `/auth/me/permissions` provider.
+ *
+ * Reads when there is a user and an app list to narrow, and again when the
+ * app list gains a name the held answer was not asked about.
+ */
+export function LauncherAppsProvider({ children, endpoint, fetcher }: LauncherAppsProviderProps) {
+ const { user } = useAuth();
+ const { apps, loading } = useMetadata();
+ const owner = user?.id != null ? String(user.id) : null;
+ // The effect keys on the names' VALUE, never on the list's identity.
+ const namesKey = JSON.stringify(activeAppNames(apps as LauncherAppLike[] | undefined));
+
+ const [held, setHeld] = useState(NOTHING_HELD);
+ // The effect's reading of the current answer, without keying on it.
+ const heldRef = useRef(NOTHING_HELD);
+ // Owners whose server answered `404`: the route is not served, so the same
+ // question is not asked again for them.
+ const notServed = useRef(new Set());
+
+ useEffect(() => {
+ const names = JSON.parse(namesKey) as string[];
+ const publish = (next: HeldAnswer) => {
+ heldRef.current = next;
+ setHeld(next);
+ };
+ if (!owner || loading || names.length === 0 || notServed.current.has(owner)) return;
+ const current = heldRef.current.owner === owner ? heldRef.current.state : null;
+ const answer = current?.status === 'ready' ? current.answer : null;
+ if (answer && names.every((name) => answer.asked.has(name))) return;
+
+ let cancelled = false;
+ const asked = new Set(names);
+ if (!current || current.status === 'idle') publish({ owner, state: { status: 'pending' } });
+ const url = endpoint ?? `${resolveRuntimeApiBase()}/me/apps`;
+ readServedAppNames(url, fetcher ?? createAuthenticatedFetch())
+ .then((served) => {
+ if (cancelled) return;
+ publish({ owner, state: { status: 'ready', answer: { served, asked } } });
+ })
+ .catch((err: unknown) => {
+ if (cancelled) return;
+ if (err instanceof LauncherAppsReadError && err.httpStatus === 404) {
+ notServed.current.add(owner);
+ } else {
+ console.warn(
+ '[app-shell] Could not read the apps your permission sets show (`GET /me/apps`); ' +
+ 'the launcher lists every app you can open instead:',
+ err,
+ );
+ }
+ // A failed re-read keeps the answer it would have replaced.
+ publish({ owner, state: answer ? { status: 'ready', answer } : { status: 'unavailable' } });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [owner, loading, namesKey, endpoint, fetcher]);
+
+ const state: LauncherAppsState = held.owner === owner ? held.state : { status: 'idle' };
+ return {children};
+}
+
+export interface LauncherApps {
+ /** The apps to offer: `filterActiveApps(apps)`, narrowed by `/me/apps`. */
+ apps: T[];
+ /** The first `/me/apps` read is still out; nothing is narrowed yet. */
+ pending: boolean;
+}
+
+/**
+ * The apps a launcher surface offers: `filterActiveApps(apps)`, minus every
+ * app the user's `/me/apps` answer was asked about and did not list. Outside
+ * a {@link LauncherAppsProvider}, or when the read is absent or failed, that
+ * is `filterActiveApps(apps)` unchanged.
+ */
+export function useLauncherApps(
+ apps: readonly T[] | null | undefined,
+): LauncherApps {
+ const state = useContext(LauncherAppsContext);
+ return {
+ apps: narrowByAnswer(state, filterActiveApps(apps)),
+ pending: state?.status === 'pending',
+ };
+}
+
+/**
+ * `apps` minus every app the user's `/me/apps` answer was asked about and did
+ * not list, with no other filter: for a surface that keeps its own rule for
+ * which apps it lists (the command palette lists `hidden` apps too) but offers
+ * none a permission set hides. Unchanged outside a provider or without an
+ * answer, exactly as {@link useLauncherApps}.
+ */
+export function useServedApps(apps: readonly T[]): T[] {
+ return narrowByAnswer(useContext(LauncherAppsContext), apps);
+}
+
+function narrowByAnswer(state: LauncherAppsState | null, apps: readonly T[]): T[] {
+ if (state?.status !== 'ready') return [...apps];
+ const { served, asked } = state.answer;
+ return apps.filter((app) => {
+ const name = app?.name;
+ return typeof name !== 'string' || served.has(name) || !asked.has(name);
+ });
+}
diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts
index 44f9fbaf060..124b25bd7e1 100644
--- a/ui/packages/app-shell/src/index.ts
+++ b/ui/packages/app-shell/src/index.ts
@@ -237,8 +237,11 @@ export {
useResponsiveSidebar,
useActionModal,
useHomePath,
+ LauncherAppsProvider,
+ useLauncherApps,
+ useServedApps,
} from './hooks/index.js';
-export type { FavoriteItem } from './hooks/index.js';
+export type { FavoriteItem, LauncherApps, LauncherAppsProviderProps } from './hooks/index.js';
// Context providers
export {
diff --git a/ui/packages/app-shell/src/layout/AppSwitcher.tsx b/ui/packages/app-shell/src/layout/AppSwitcher.tsx
index b58b5bd0b47..d4b6058ebce 100644
--- a/ui/packages/app-shell/src/layout/AppSwitcher.tsx
+++ b/ui/packages/app-shell/src/layout/AppSwitcher.tsx
@@ -37,6 +37,7 @@ import { useMetadata } from '../providers/MetadataProvider.js';
import { resolveKeyedI18nLabel, matchAppBySegment, appRouteSegment } from '../utils/index.js';
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import { useHomePath } from '../hooks/useHomePath.js';
+import { useLauncherApps } from '../hooks/useLauncherApps.js';
import { SETUP_APP_NAME, SETUP_APP_PACKAGE_ID } from '../console/ConsoleShell.js';
import { GO_HOME_SHORTCUT, shortcutKeycaps } from '../chrome/advertisedShortcuts.js';
import { isSingleAppSwitcherHidden } from '../runtime-config.js';
@@ -83,7 +84,12 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }:
// this list is already permitted. Filtering it here would hide a builder's own
// in-progress app from the builder. Pinned by
// `__tests__/AppSwitcher.publishState.test.tsx`.
- const activeApps = apps.filter((a: any) => a.active !== false && a.hidden !== true);
+ //
+ // An app the user's permission sets hide (`tabPermissions: hidden`) is not
+ // offered either: the menu offers what Home's tiles offer, narrowed by the
+ // `/me/apps` answer (objectstack#26838). The pill below still names such an app when the user
+ // is in it — it opens from a direct URL, so it is where they are.
+ const { apps: activeApps } = useLauncherApps(apps);
// ADR-0048 (A) — route segment may be a package id; match by it (name fallback).
const activeApp = matchAppBySegment(activeApps, activeAppName) || matchAppBySegment(apps.filter((a: any) => a.active !== false), activeAppName) || activeApps[0];
diff --git a/ui/packages/app-shell/src/layout/__tests__/AppSwitcher.launcherHiddenApps-26838.test.tsx b/ui/packages/app-shell/src/layout/__tests__/AppSwitcher.launcherHiddenApps-26838.test.tsx
new file mode 100644
index 00000000000..28e338de772
--- /dev/null
+++ b/ui/packages/app-shell/src/layout/__tests__/AppSwitcher.launcherHiddenApps-26838.test.tsx
@@ -0,0 +1,132 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#26838 — the App Switcher's menu offers what Home's tiles offer:
+ * the active `/meta/app` list narrowed by the user's `GET /me/apps` answer, so
+ * an app a permission set hides (`tabPermissions: hidden`) is not in it. The
+ * pill still names that app while the user is in it — it opens from a URL.
+ *
+ * The menu harness is `AppSwitcher.publishState.test.tsx`'s.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+// Passthrough dropdown primitives so the menu CONTENT renders without an
+// interaction — the same idiom as WorkspaceSwitcher.test.tsx. What is under
+// measurement is which apps reach the list, not radix's open/close machinery.
+vi.mock('@object-ui/components', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ DropdownMenu: (p: any) =>
{p.children}
,
+ DropdownMenuTrigger: (p: any) =>
{p.children}
,
+ // Scoped: the trigger renders the ACTIVE app's label too, so an unscoped
+ // `getByText` would match twice. Every assertion below reads the LIST.
+ DropdownMenuContent: (p: any) =>
{p.children}
,
+ DropdownMenuItem: ({ onClick, children }: any) =>
{children}
,
+ DropdownMenuLabel: (p: any) => ,
+ DropdownMenuSeparator: () => ,
+}));
+vi.mock('lucide-react', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ ChevronDown: () => ,
+ Check: () => ,
+}));
+// `getIcon` wraps lucide's DynamicIcon (a per-icon dynamic import); stub it so
+// no unbounded module load races the assertions.
+vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => }));
+
+// Spread the real module rather than replacing it: other modules in this graph
+// import further exports from it, and a bare factory would blank them out.
+vi.mock('@object-ui/i18n', async () => {
+ const actual = await vi.importActual>('@object-ui/i18n');
+ return {
+ ...actual,
+ useObjectTranslation: () => ({
+ t: (key: string, o?: { defaultValue?: string }) => o?.defaultValue ?? key,
+ }),
+ useObjectLabel: () => ({
+ appLabel: ({ name, label }: { name: string; label?: string }) => label ?? name,
+ }),
+ };
+});
+
+// Keep the real route-segment matching (`appRouteSegment` / `matchAppBySegment`);
+// only the keyed-i18n resolver is stubbed.
+vi.mock('../../utils', async () => {
+ const appRoute = await vi.importActual('../../utils/appRoute');
+ return {
+ ...appRoute,
+ resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : undefined),
+ };
+});
+
+let apps: Array> = [];
+vi.mock('../../providers/MetadataProvider', () => ({
+ useMetadata: () => ({ apps, loading: false }),
+}));
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuth: () => ({ user: { id: 'u1' } }),
+}));
+
+import { AppSwitcher } from '../AppSwitcher';
+import { LauncherAppsProvider } from '../../hooks/useLauncherApps';
+
+/** A `/me/apps` double: the listed names, or a bare status. */
+function meApps(answer: string[] | number) {
+ return vi.fn(async (_url: unknown) =>
+ typeof answer === 'number'
+ ? new Response('{}', { status: answer })
+ : new Response(JSON.stringify({ apps: answer.map((name) => ({ name })) }), { status: 200 }),
+ );
+}
+
+async function renderSwitcher(fetcher: unknown, active = 'crm') {
+ render(
+
+
+
+
+ ,
+ );
+ // Let the double's answer land and be drawn.
+ await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ return within(screen.getByTestId('app-switcher-menu'));
+}
+
+beforeEach(() => {
+ apps = [
+ { name: 'crm', label: 'CRM' },
+ { name: 'showcase_app', label: 'Showcase' },
+ { name: 'ops', label: 'Ops' },
+ ];
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('AppSwitcher offers only the apps /me/apps lists (objectstack#26838)', () => {
+ it('an app the member’s set hides is not in the menu', async () => {
+ const menu = await renderSwitcher(meApps(['crm', 'ops']));
+ expect(menu.getByText('CRM')).toBeInTheDocument();
+ expect(menu.getByText('Ops')).toBeInTheDocument();
+ expect(menu.queryByText('Showcase')).toBeNull();
+ });
+
+ it('CONTROL — a 404 (no security plugin) offers every app, as before', async () => {
+ const menu = await renderSwitcher(meApps(404));
+ expect(menu.getByText('Showcase')).toBeInTheDocument();
+ });
+
+ it('the pill still names a hidden app while the user is in it', async () => {
+ const menu = await renderSwitcher(meApps(['crm', 'ops']), 'showcase_app');
+ expect(screen.getByTestId('app-switcher-trigger')).toHaveAttribute('title', 'Showcase');
+ expect(menu.queryByText('Showcase')).toBeNull();
+ });
+});
diff --git a/ui/packages/app-shell/src/views/app-launcher-renderer.tsx b/ui/packages/app-shell/src/views/app-launcher-renderer.tsx
index 7b552ed7606..2e90735f84d 100644
--- a/ui/packages/app-shell/src/views/app-launcher-renderer.tsx
+++ b/ui/packages/app-shell/src/views/app-launcher-renderer.tsx
@@ -40,9 +40,11 @@
* eagerly (`app` is in its `EAGER_TYPES`, i.e. `GET /api/v1/meta/app` on
* mount). That is the SAME read the top-bar `AppSwitcher` and the Home page
* make, and the openable-apps filter is the shared `filterActiveApps` helper
- * rather than a second copy of the `active`/`hidden` rule. This is the ruling's
- * "no external data-source dependency" claim, discharged: the block issues no
- * request of its own and reaches no adapter.
+ * rather than a second copy of the `active`/`hidden` rule, narrowed by
+ * `useLauncherApps` — the user's `/me/apps` answer, which the console's one
+ * `LauncherAppsProvider` reads for Home, the switcher and this block alike.
+ * This is the ruling's "no external data-source dependency" claim, discharged:
+ * the block issues no request of its own and reaches no adapter.
*
* The grid itself is `HomeAppsStrip` — the console's existing launcher, already
* the answer to "how does this product draw a wall of apps" — so an authored
@@ -82,13 +84,12 @@
*/
import * as React from 'react';
-import { useMemo } from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { useMetadata } from '@object-ui/react';
import { useObjectTranslation } from '@object-ui/i18n';
import { HomeAppsStrip } from '../console/home/HomeAppsStrip.js';
import { useFavorites } from '../hooks/useFavorites.js';
-import { filterActiveApps } from '../utils/index.js';
+import { useLauncherApps } from '../hooks/useLauncherApps.js';
/** Keep the designer's own data attributes on the wrapper, drop the rest. */
const splitDesigner = (props: Record) => {
@@ -113,8 +114,9 @@ export const AppLauncherRenderer: React.FC = ({
// The openable set, through the SHARED predicate — `active !== false &&
// hidden !== true`. Two copies of "an app the user can open" is precisely
- // the dialect `filterActiveApps` exists to prevent.
- const openable = useMemo(() => filterActiveApps(apps as any[]), [apps]);
+ // the dialect `filterActiveApps` exists to prevent. Narrowed, as Home's own
+ // tiles are, by the user's `/me/apps` answer (objectstack#26838).
+ const { apps: openable } = useLauncherApps(apps as any[]);
return (