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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/home-ai-studio-flag-5521.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 57 additions & 12 deletions packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -552,17 +572,42 @@ export function HomePage() {
{isAdmin && (
<div className="mb-6">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<BuilderCoverCard
icon={Hammer}
title={t('home.build.title', { defaultValue: 'Build an app' })}
subtitle={t('home.build.subtitle', {
defaultValue: 'Start from scratch — design objects, forms, automations and interfaces.',
})}
onClick={() => 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 && (
<BuilderCoverCard
icon={Hammer}
title={t('home.build.title', { defaultValue: 'Build an app' })}
subtitle={t('home.build.subtitle', {
defaultValue: 'Start from scratch — design objects, forms, automations and interfaces.',
})}
onClick={() => navigate('/studio')}
disabled={!canAuthorMetadata}
disabledReason={authoringGateReason}
testId="home-build-app"
/>
)}
<BuilderCoverCard
icon={LayoutTemplate}
title={t('home.template.title', { defaultValue: 'Start with a template' })}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Home stops offering the metadata-authoring front door on a deployment whose
* own runtime config says authoring is not offered here (objectui#5521).
*
* ## The defect
*
* On the composed hosted-SaaS shape (`OS_TENANCY_POSTURE=isolated`), Home led a
* plain tenant with "Build an app — start from scratch, design objects, forms,
* automations and interfaces". Behind it: `/api/v1/meta/*` → 403 and a
* ToolRegistry holding zero authoring handlers. The lockdown criterion for that
* shape is two-part — UI entry hidden AND API refused — and only the backend
* half was green.
*
* `features.aiStudio` is the server's own answer to "is AI-driven metadata
* authoring offered by this runtime". It was *wrong* on this shape until
* cloud#1471 derived it from the same resolution that decides the agent mount;
* before that it leaked a default `true` and honouring it here would have
* hidden nothing. It is truthful now, and this page did not read it.
*
* ## Why HIDDEN rather than dimmed
*
* The flag's declared meaning, on both sides of the wire, is *hide*:
* `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". The sibling `features.marketplace` means
* something different — reachability of a route — which is why objectui#5504
* correctly rendered *it* as a dimmed card plus a visible reason. Two flags,
* two declared meanings, two presentations.
*
* ## Why these cases drive the REAL runtime-config module
*
* Verbatim from `HomePage.marketplaceDisabled.test.tsx`: the claim under test is
* that the gate consumes the SERVER's `features.aiStudio`, so every case boots
* the genuine `initRuntimeConfig()` over a stubbed `GET /api/v1/runtime/config`
* rather than mocking an accessor. Mocking the accessor would pass against a
* page that never asks the server anything.
*
* ## Why `t` returns «key»
*
* The reason lines must resolve THROUGH i18n. Verbatim from
* `HomePage.authoringCapabilityGate.test.tsx`, which states the argument in full.
*
* ## Guarding the vacuum
*
* The acceptance condition is that something does NOT render, which an empty
* page reproduces perfectly. So every denial case also asserts the furniture it
* is denying *around* — the app tile the strip renders and the sibling template
* cover — and the counter-probes drive the SAME fixture through the SAME helper
* with the flag flipped and find the card. An empty render fails the denials'
* counter-assertions; a gate stuck open fails the denials; a gate that swept the
* whole cover fails the sibling assertion. None of the three can pass alone.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';

const navigateMock = vi.fn();

vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
}));

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<string, unknown>) => ({
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<string, unknown>) {
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(
<MePermissionsProvider initialPermissions={permissions()}>
<HomePage />
</MePermissionsProvider>,
);
}

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();
});
});
Loading