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
10 changes: 10 additions & 0 deletions .changeset/12700-nav-can-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/app-shell': minor
'@object-ui/react': minor
---

A navigation entry whose `visible` predicate cannot be evaluated is no longer drawn for everyone (objectui#12700).

The shell's navigation surfaces (the sidebar, the app root's landing, the activity roster read from app navigation, the `nav:menu` block and the command palette's navigation list) answer a FAULTING `visible` predicate by withholding the entry, where they used to draw it. The case that made this matter is a `current_user.can("OBJECT", "read")` gate asked where no `/auth/me/permissions` payload is bound: the expression engine refuses it, and the shell used to read that refusal as "shown", so permission-gated navigation hid nothing there. Where the payload is bound, the gate answers from it as before: a member without the grant is not shown the entry and one with it is. The fault is still reported once per predicate, now with a consequence line saying the entry was withheld. A blank gate is still "no gate" and is drawn. This is UI gating, not a security boundary: the server enforces what a member may read.

`@object-ui/react`: `PredicateGateKind` gains a `'withholding'` member, the consequence line for a visibility gate whose fault is answered by withholding the entry. `@object-ui/app-shell`'s published `evaluateVisibility` keeps its fail-open answer; the object-field gate uses it unchanged.
4 changes: 2 additions & 2 deletions packages/app-shell/src/chrome/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
import { useRecordSearch } from '@object-ui/react';
import { usePermissions } from '@object-ui/permissions';
import { useTheme } from './ThemeProvider.js';
import { useExpressionContext, evaluateVisibility } from '../providers/ExpressionProvider.js';
import { useExpressionContext, evaluateNavigationVisibility } from '../providers/ExpressionProvider.js';
import { useObjectTranslation } from '@object-ui/i18n';
import { resolveKeyedI18nLabel, getRecordDisplayName, appRouteSegment } from '../utils/index.js';
import { getIcon } from '../utils/getIcon.js';
Expand Down Expand Up @@ -142,7 +142,7 @@ export function CommandPalette(props: CommandPaletteProps) {

// Extract navigation items from active app, filtering by visibility expressions
const navItems = flattenNavigation(activeApp?.navigation || []).filter(
(item) => evaluateVisibility(item.visible ?? item.visibleOn, evaluator)
(item) => evaluateNavigationVisibility(item.visible ?? item.visibleOn, evaluator)
);

// The entries the palette shows for the query: a navigation entry or app by
Expand Down
10 changes: 8 additions & 2 deletions packages/app-shell/src/hooks/useNavMemberGates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@
* an unchecked member. With no permission provider mounted, `can` answers
* `true`, exactly as it does for the sidebar.
*
* The `visible` evaluation is the exception to that "unknown reads as yes", and
* deliberately (objectui#12700): it goes through `evaluateNavigationVisibility`,
* so a predicate the evaluator cannot answer — `current_user.can(...)` where no
* permissions payload is bound, as on a frame with no permission provider —
* withholds its entry and reports once, instead of drawing it for everyone.
*
* Internal module: not re-exported by the package entry.
*/

Expand All @@ -49,7 +55,7 @@ import { hasVisibleNavigationItems, type NavigationVisibilityOptions } from '@ob
import type { NavigationItem } from '@object-ui/types';
import { usePermissions } from '@object-ui/permissions';
import { useMetadata } from '../providers/MetadataProvider.js';
import { useExpressionContext, evaluateVisibility } from '../providers/ExpressionProvider.js';
import { useExpressionContext, evaluateNavigationVisibility } from '../providers/ExpressionProvider.js';
import { withoutUnreadableObjectEntries, type ObjectReadCheck } from '../utils/navObjectReadGate.js';

/** The member's gates, as the sidebar applies them. */
Expand All @@ -71,7 +77,7 @@ export interface NavMemberGates {
export function useNavMemberGates(): NavMemberGates {
const { evaluator } = useExpressionContext();
const evalVis = useCallback(
(expr: string | boolean | undefined) => evaluateVisibility(expr, evaluator),
(expr: string | boolean | undefined) => evaluateNavigationVisibility(expr, evaluator),
[evaluator],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

// Lazy lucide DynamicIcon would suspend mid-test; a null icon is enough here.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ vi.mock('../../providers/ExpressionProvider', () => ({
// Mirrors the real evaluateVisibility's literal handling — enough for
// fixtures gating with `visible: false`.
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ vi.mock('@object-ui/permissions', async (importOriginal) => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* UnifiedSidebar — a navigation entry gated on `current_user.can(object, verb)`
* follows the member's `/auth/me/permissions` answer, and is WITHHELD wherever
* that answer is not bound (objectui#12700).
*
* HotCRM declares `visible: "current_user.can(\"crm_opportunity\", \"read\")"`
* on a navigation entry. The fixture gates a `dashboard` entry, NOT an `object`
* entry on `crm_opportunity`: an object entry is already pruned by the read
* gate (objectui#12109), which would hide it whatever the predicate said and
* leave this file measuring the wrong gate.
*
* Everything on the path is real — `MePermissionsProvider` (fed through its
* `fetcher`, i.e. only the network is mocked), `ExpressionProvider` binding the
* payload for `can`, `MetadataProvider`, `@object-ui/layout`'s item guard —
* except the chrome hooks the sibling harness
* `UnifiedSidebar.objectReadGate-12109.test.tsx` also stubs (labels, recents,
* pins, context selectors), none of which decides visibility.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import React from 'react';
import { render, screen, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import type { NavigationItem } from '@object-ui/types';
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';
import { __resetVisibilityPredicateWarnings } from '@object-ui/react';

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
language: 'en',
}),
useObjectLabel: () => ({
objectLabel: ({ label }: { label?: string }) => label,
viewLabel: (_o: string, _v: string, fallback?: string) => fallback,
dashboardLabel: ({ label }: { label?: string }) => label,
appLabel: ({ label }: { label?: string }) => label,
}),
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: null, activeOrganization: null }),
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
}));

vi.mock('../../utils', () => ({
resolveKeyedI18nLabel: (label: unknown) => (typeof label === 'string' ? label : ''),
matchAppBySegment: (apps: Array<{ name?: string }>, segment?: string) =>
apps.find((a) => a?.name === segment),
appRouteSegment: (app: { name?: string }) => app?.name,
}));

vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null }));
vi.mock('../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: [] }) }));
vi.mock('../../hooks/useFavorites', () => ({
useFavorites: () => ({ favorites: [], removeFavorite: vi.fn() }),
}));
vi.mock('../../hooks/useNavPins', () => ({
useNavPins: () => ({ togglePin: vi.fn(), applyPins: (items: unknown) => items }),
}));
vi.mock('../../hooks/useNavActionDispatch', () => ({
useNavActionDispatch: () => vi.fn(),
}));
vi.mock('../../context/NavigationContext', () => ({
useNavigationContext: () => ({ context: 'app', currentAppName: 'crm' }),
}));
vi.mock('../ContextSelectors', () => ({
useAppContextSelectors: () => ({ contextValues: {}, element: null }),
contextSelectorQueryKey: (id: string) => (id === 'active_package' ? 'package' : id),
STUDIO_PACKAGE_SELECTOR_ID: 'active_package',
}));
vi.mock('../LocalizedSidebarTrigger', () => ({
LocalizedSidebarTrigger: () => null,
}));

import { SidebarProvider } from '@object-ui/components';
import { MetadataProvider } from '../../providers/MetadataProvider';
import { ExpressionProvider } from '../../providers/ExpressionProvider';
import { UnifiedSidebar } from '../UnifiedSidebar';

/** The served shape: the spec normalizes an authored `visible` string into a CEL envelope. */
const CAN_READ_OPPORTUNITY = { dialect: 'cel', source: 'current_user.can("crm_opportunity", "read")' };

const NAVIGATION: NavigationItem[] = [
{ id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts' },
{ id: 'nav_pipeline', type: 'dashboard', dashboardName: 'pipeline', label: 'Pipeline', visible: CAN_READ_OPPORTUNITY as never },
{ id: 'nav_help', type: 'url', url: '/help', label: 'Help' },
];

const CRM_APP = { name: 'crm', label: 'CRM', active: true, navigation: NAVIGATION };

function answerFor(opportunityRead: boolean): MePermissionsResponse {
return {
authenticated: true,
userId: 'u_agent',
tenantId: null,
roles: [],
permissionSets: ['service_agent'],
systemPermissions: [],
objects: {
crm_account: { allowRead: true },
crm_opportunity: { allowRead: opportunityRead },
},
fields: {},
} as MePermissionsResponse;
}

function adapterFor(app: Record<string, unknown>) {
return {
clearCache: vi.fn(),
getClient: () => ({
meta: {
getItems: (type: string) => Promise.resolve({ type, items: type === 'app' ? [app] : [] }),
getItem: () => Promise.resolve({ item: null }),
},
}),
} as unknown as Parameters<typeof MetadataProvider>[0]['adapter'];
}

/** The console's in-app mount: the sidebar under `ExpressionProvider`. */
function shell() {
return (
<MetadataProvider adapter={adapterFor(CRM_APP)}>
<MemoryRouter initialEntries={['/apps/crm']}>
<ExpressionProvider user={{ id: 'u_agent', name: 'Agent' }}>
<SidebarProvider>
<UnifiedSidebar activeAppName="crm" />
</SidebarProvider>
</ExpressionProvider>
</MemoryRouter>
</MetadataProvider>
);
}

function respond(answer: MePermissionsResponse): Response {
return { ok: true, status: 200, json: async () => answer } as unknown as Response;
}

function renderWithPermissions(fetcher: (...args: unknown[]) => Promise<Response>) {
return render(
<MePermissionsProvider
endpoint="/api/v1/auth/me/permissions"
fetcher={fetcher as unknown as typeof fetch}
loadingFallback={<div data-testid="permissions-loading" />}
>
{shell()}
</MePermissionsProvider>,
);
}

const link = (name: string) => screen.queryByRole('link', { name });
const warnings = (spy: { mock: { calls: unknown[][] } }) =>
spy.mock.calls.map((c) => c.map(String).join(' ')).join('\n');

let warn: MockInstance<typeof console.warn>;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
__resetVisibilityPredicateWarnings();
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
});

describe('UnifiedSidebar — a `current_user.can()` navigation gate follows `/auth/me/permissions` (objectui#12700)', () => {
it('(a) a member without read on the object is not shown the gated entry, and nothing is reported', async () => {
renderWithPermissions(async () => respond(answerFor(false)));
await waitFor(() => expect(link('Accounts')).toBeInTheDocument());
expect(link('Pipeline')).not.toBeInTheDocument();
// A denial is a verdict, not a fault: the refusal line is not printed.
expect(warnings(warn)).not.toMatch(/app-shell:visible/);
});

it('(b) a member with read is shown it', async () => {
renderWithPermissions(async () => respond(answerFor(true)));
await waitFor(() => expect(link('Pipeline')).toHaveAttribute('href', '/apps/crm/dashboard/pipeline'));
expect(warnings(warn)).not.toMatch(/app-shell:visible/);
});

it('(c) while the answer is pending the entry is absent; it appears once the answer grants read', async () => {
let answer!: (r: Response) => void;
const pending = new Promise<Response>((resolve) => { answer = resolve; });
const fetcher = vi.fn(() => pending);
renderWithPermissions(fetcher);
// The console's posture: the provider withholds the whole shell, not one entry.
expect(screen.getByTestId('permissions-loading')).toBeInTheDocument();
expect(link('Pipeline')).not.toBeInTheDocument();
await act(async () => { answer(respond(answerFor(true))); });
await waitFor(() => expect(link('Pipeline')).toBeInTheDocument());
expect(fetcher).toHaveBeenCalledTimes(1);
});

it('(d) CONTROL: ungated entries are drawn for the member the gate hides from', async () => {
renderWithPermissions(async () => respond(answerFor(false)));
await waitFor(() => expect(link('Accounts')).toBeInTheDocument());
expect(link('Help')).toBeInTheDocument();
});

it('with no permissions payload bound the gated entry is WITHHELD, the rest drawn, and the refusal reported once', async () => {
// A frame with no permission provider (the Home and Studio frames).
render(shell());
await waitFor(() => expect(link('Accounts')).toBeInTheDocument());
expect(link('Help')).toBeInTheDocument();
expect(link('Pipeline')).not.toBeInTheDocument();
const lines = warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes('app-shell:visible'));
expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(/carries no permission data/);
expect(lines[0]).toMatch(/WITHHELD/);
expect(lines[0]).not.toMatch(/did NOT bite/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ vi.mock('../../providers/MetadataProvider', () => ({
vi.mock('../../providers/ExpressionProvider', () => ({
useExpressionContext: () => ({ evaluator: null }),
evaluateVisibility: (expr: unknown) => expr !== false && expr !== 'false',
evaluateNavigationVisibility: (expr: unknown) => expr !== false && expr !== 'false',
}));

vi.mock('../../utils', () => ({
Expand Down
Loading
Loading