diff --git a/.changeset/home-ai-studio-flag-5521.md b/.changeset/home-ai-studio-flag-5521.md
new file mode 100644
index 000000000..411aa0a99
--- /dev/null
+++ b/.changeset/home-ai-studio-flag-5521.md
@@ -0,0 +1,34 @@
+---
+'@object-ui/app-shell': minor
+---
+
+Console Home stops offering the metadata-authoring front door on a deployment
+whose own runtime config says authoring is not offered there (objectui#5521).
+
+The "Build an app" cover card is now withheld when
+`GET /api/v1/runtime/config` reports `features.aiStudio: false`. On the composed
+hosted-SaaS shape that card led a plain tenant into the full authoring flow
+behind a runtime whose `/api/v1/meta/*` answers `403` and whose ToolRegistry
+holds zero authoring handlers — the entry was offered and the refusal arrived at
+submit. The lockdown criterion for that shape is two-part, UI entry hidden AND
+API refused; only the backend half was green.
+
+- The card is **hidden, not dimmed**, because that is the flag's own declared
+ meaning on both sides of the wire: `RuntimeFeatures.aiStudio` documents "when
+ false, the SPA hides the AI authoring affordances", and the serving plugin
+ documents "set false to force-hide the authoring UI".
+- `features.marketplace` keeps the different presentation objectui#5504 gave it
+ — a dimmed card plus a visible localized reason. That flag means a route is
+ reachable; this one means force-hide. "Start with a template" is untouched:
+ installing a marketplace package is not AI metadata authoring and answers to
+ its own flags.
+- No reason line is rendered in its place. `home.build.noCapability` says the
+ *account* lacks "Manage Metadata"; on a runtime with no authoring at all the
+ surface is absent for everyone, and pointing a viewer at a permission that
+ would not help them is the misdirection objectui#5557 is about.
+- Unknown fails **OPEN** (`!== false`), the doctrine `isMarketplaceEnabled()`
+ already encodes: a runtime predating the flag, or one whose config fetch
+ failed, keeps the card exactly as visible as before.
+
+No new authorable config key, no new server surface, and no new copy — the flag
+was already being served and already reaches the browser.
diff --git a/packages/app-shell/src/console/home/HomePage.tsx b/packages/app-shell/src/console/home/HomePage.tsx
index ae6a26841..2ad50ef7c 100644
--- a/packages/app-shell/src/console/home/HomePage.tsx
+++ b/packages/app-shell/src/console/home/HomePage.tsx
@@ -39,7 +39,7 @@ import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText, Hammer, Layou
import { useMetadataClient } from '../../views/metadata-admin/useMetadata.js';
import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts.js';
import { resolveAiApiBase } from '../../hooks/useAiSurface.js';
-import { isMarketplaceEnabled } from '../../runtime-config.js';
+import { getRuntimeConfig, isMarketplaceEnabled } from '../../runtime-config.js';
/**
* Which AI home CTAs to surface, driven by the live agent catalog (the single
@@ -379,6 +379,26 @@ export function HomePage() {
// used to recommend it first and error afterwards; that ORDERING was the
// injury, so the recommendation goes when the capability does.
const marketplaceEnabled = isMarketplaceEnabled();
+ // objectui#5521 — the DEPLOYMENT's own answer to "is metadata authoring
+ // offered here at all", distinct from the per-principal `canAuthorMetadata`
+ // above. `features.aiStudio` is derived server-side from the same resolution
+ // that decides whether the authoring agent is mounted, so on the composed
+ // hosted-SaaS shape it arrives `false` while the ToolRegistry holds zero
+ // authoring handlers and `/api/v1/meta/*` answers 403.
+ //
+ // Read inline rather than through a new accessor, so this card adds no
+ // export; lifting this and `ChatDock`'s identical read onto an
+ // `isAiStudioEnabled()` sibling of `isMarketplaceEnabled()` is filed as a
+ // follow-up rather than done here, where it would mean editing four
+ // neighbouring suites' module mocks.
+ //
+ // `features?.` and `!== false` are both load-bearing and are copied from
+ // `isMarketplaceEnabled()`'s body rather than invented: hosts (and four
+ // sibling suites) supply a runtime-config snapshot carrying only `branding`,
+ // so `features` is genuinely absent on real code paths — and an absent flag
+ // must fail OPEN. Withholding the product's front door over an unanswered
+ // question is the worse direction; the server refuses the write regardless.
+ const aiStudioEnabled = getRuntimeConfig().features?.aiStudio !== false;
// Shown wherever an authoring entry point is withheld, so the posture is
// explained on screen instead of surfacing as a refusal after a filled-in
// dialog. Localized in all ten packs.
@@ -552,17 +572,42 @@ export function HomePage() {
{isAdmin && (
- navigate('/studio')}
- disabled={!canAuthorMetadata}
- disabledReason={authoringGateReason}
- testId="home-build-app"
- />
+ {/* objectui#5521 — HIDDEN, not dimmed, when the runtime reports
+ * `features.aiStudio: false`. The distinction is the flag's own
+ * declared meaning, not a presentation preference: `RuntimeFeatures`
+ * documents it as "when false, the SPA HIDES the AI authoring
+ * affordances", and the serving plugin as "set false to force-hide
+ * the authoring UI". Honouring a producer's declared semantics is
+ * the whole point of reading its flag.
+ *
+ * It is also the honest answer here. `!canAuthorMetadata` is a fact
+ * about THIS PRINCIPAL — a dimmed card plus a reason line tells an
+ * admin something actionable about their own account, which is why
+ * that case stays disabled-with-reason. `aiStudio: false` is a fact
+ * about the DEPLOYMENT: authoring exists for nobody here, there is no
+ * permission to acquire and no admin to ask, so a permanently greyed
+ * front door would advertise a room that was never built. That is the
+ * misdirection class objectui#5557 names — reporting the wrong KIND
+ * of answer — applied to a card instead of a page.
+ *
+ * Deliberately NOT extended to "Start with a template": installing a
+ * marketplace package is not AI metadata authoring, it has its own
+ * flags (`features.marketplace` / `installLocal`), and objectui#5504
+ * already ruled disable-and-explain for it. Its flag means
+ * reachability; this one means force-hide. */}
+ {aiStudioEnabled && (
+ navigate('/studio')}
+ disabled={!canAuthorMetadata}
+ disabledReason={authoringGateReason}
+ testId="home-build-app"
+ />
+ )}
({
+ useNavigate: () => navigateMock,
+}));
+
+vi.mock('@object-ui/i18n', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useObjectTranslation: () => ({
+ t: (key: string) => `«${key}»`,
+ language: 'en',
+ }),
+ useObjectLabel: () => ({ appLabel: (app: any) => String(app?.label ?? app?.name ?? '') }),
+}));
+
+vi.mock('@object-ui/auth', () => ({
+ useAuth: () => ({ user: { id: 'u1', name: 'Zhang San', email: 'zhangsan@acme-test.com' } }),
+ useIsWorkspaceAdmin: () => true,
+}));
+
+vi.mock('@object-ui/plugin-chatbot', () => ({
+ useAgents: () => ({ agents: [{ name: 'builder' }] }),
+ isAskAgent: () => false,
+ agentHasCapability: () => true,
+}));
+
+vi.mock('../../../providers/MetadataProvider', () => ({
+ useMetadata: () => ({ apps: [{ name: 'crm', label: 'CRM' }], loading: false }),
+}));
+
+vi.mock('../../../context/NavigationContext', () => ({
+ useNavigationContext: () => ({ currentAppName: undefined }),
+}));
+
+// --- surfaces unrelated to this gate ---------------------------------------
+vi.mock('../../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: [] }) }));
+vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites: [] }) }));
+vi.mock('../../../hooks/useHomeInbox', () => ({
+ useHomeInbox: () => ({
+ pendingApprovalsCount: 0,
+ notifications: [],
+ unreadTopicCount: 0,
+ activities: [],
+ }),
+}));
+vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
+vi.mock('../../../views/metadata-admin/useMetadata', () => ({
+ useMetadataClient: () => ({ listDrafts: async () => [] }),
+}));
+vi.mock('../../../preview/usePublishAllDrafts', () => ({
+ usePublishAllDrafts: () => ({ publishAll: async () => ({ ok: true }), publishing: false }),
+}));
+
+import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
+import { HomePage } from '../HomePage';
+
+/**
+ * A `GET /api/v1/runtime/config` answer, as the server sends it.
+ *
+ * `features` is spread from a caller-supplied partial rather than built from
+ * named booleans, so a case can OMIT `aiStudio` entirely — the fail-open case
+ * below turns on the difference between "absent" and "false", and a signature
+ * taking `aiStudio: boolean` could not express it.
+ */
+const serverConfig = (features: Record) => ({
+ cloudUrl: 'https://cloud.objectos.ai',
+ singleEnvironment: true,
+ features: { installLocal: true, marketplace: true, autoPublishAiBuilds: true, ...features },
+ branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
+});
+
+async function bootOn(body: Record) {
+ resetRuntimeConfigForTesting();
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => body })));
+ await initRuntimeConfig();
+}
+
+/**
+ * An admin who MAY author metadata — so the PRINCIPAL half of the gate is wide
+ * open and only the DEPLOYMENT flag can move the verdict. Without
+ * `manage_metadata` here, every denial below would pass for the wrong reason.
+ */
+const permissions = (): MePermissionsResponse =>
+ ({
+ authenticated: true,
+ userId: 'u1',
+ tenantId: 'acme',
+ roles: ['org_owner', 'everyone'],
+ permissionSets: ['organization_admin', 'member_default'],
+ systemPermissions: ['manage_org_users', 'setup.access', 'setup.write', 'manage_metadata'],
+ objects: {},
+ fields: {},
+ }) as MePermissionsResponse;
+
+function renderHome() {
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ navigateMock.mockReset();
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ resetRuntimeConfigForTesting();
+});
+
+describe('Home on a runtime reporting features.aiStudio: false (objectui#5521)', () => {
+ beforeEach(async () => {
+ await bootOn(serverConfig({ aiStudio: false }));
+ });
+
+ it('withholds the "Build an app" cover entirely — not dimmed, gone', () => {
+ renderHome();
+
+ expect(screen.queryByTestId('home-build-app')).toBeNull();
+ // Counter-probe against a page that rendered NOTHING: the strip behind the
+ // cover is still there with the workspace's app on it.
+ expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument();
+ });
+
+ it('leaves the sibling template cover standing — this gate is not a cover-wide sweep', () => {
+ renderHome();
+
+ // "Start with a template" installs a marketplace package; that is not AI
+ // metadata authoring and it answers to its own flags. A gate that swept the
+ // whole builder cover would remove it from every runtime that merely
+ // switched AI Studio off, and would still pass the assertion above.
+ const template = screen.getByTestId('home-start-template');
+ expect(template).toBeInTheDocument();
+ expect(template).toBeEnabled();
+ });
+
+ it('offers no route into the authoring surface it just withheld', () => {
+ renderHome();
+
+ // The withheld card's destination. Nothing on the rendered page may reach
+ // `/studio` on its own — `StudioRoute` would bounce this principal anyway,
+ // and walking them there to be bounced is the defect, not the remedy.
+ expect(navigateMock).not.toHaveBeenCalledWith('/studio');
+ });
+
+ it('does not borrow the per-principal reason line to explain a deployment fact', () => {
+ renderHome();
+
+ // `home.build.noCapability` says the account lacks "Manage Metadata". This
+ // admin HOLDS it (see `permissions()`); the surface is absent for everyone.
+ // Rendering that line here would be the objectui#5557 misdirection —
+ // sending a viewer to ask for a permission that would not help them.
+ expect(screen.queryByTestId('home-authoring-gate-reason')).toBeNull();
+ });
+});
+
+describe('Home on the measured composed hosted-SaaS shape (objectui#5521)', () => {
+ it('withholds authoring while keeping objectui#5504’s explained marketplace state', async () => {
+ // Both flags false at once — the shape the card measured. The two gates must
+ // compose rather than shadow each other.
+ await bootOn(serverConfig({ aiStudio: false, marketplace: false, installLocal: false }));
+ renderHome();
+
+ expect(screen.queryByTestId('home-build-app')).toBeNull();
+ expect(screen.getByTestId('home-start-template')).toBeDisabled();
+ expect(screen.getByTestId('home-marketplace-disabled-reason')).toHaveTextContent(
+ '«home.template.marketplaceDisabled»',
+ );
+ expect(screen.queryByTestId('browse-marketplace-btn')).toBeNull();
+ expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument();
+ });
+});
+
+describe('Home on a runtime that DOES offer AI Studio', () => {
+ it('keeps the "Build an app" cover exactly as before', async () => {
+ // Same fixture, same helper, one flag flipped — so the denials above cannot
+ // be passing because the card never renders in this suite at all.
+ await bootOn(serverConfig({ aiStudio: true }));
+ renderHome();
+
+ const build = screen.getByTestId('home-build-app');
+ expect(build).toBeInTheDocument();
+ expect(build).toBeEnabled();
+ });
+
+ it('fails OPEN when the runtime reports no aiStudio key at all', async () => {
+ // A server predating the flag answers a `features` map without it. Absent is
+ // not `false`: withholding the product's front door on an unanswered
+ // question is the worse direction, and it is the doctrine `!== false`
+ // already encodes for `features.marketplace`.
+ await bootOn(serverConfig({}));
+ renderHome();
+
+ expect(screen.getByTestId('home-build-app')).toBeEnabled();
+ });
+
+ it('fails OPEN when the runtime answers no config at all', async () => {
+ resetRuntimeConfigForTesting();
+ vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) })));
+ await initRuntimeConfig();
+
+ renderHome();
+
+ expect(screen.getByTestId('home-build-app')).toBeEnabled();
+ });
+});