Repository navigation
fix(app-shell): an object entry and its list actions are not drawn for a caller who cannot read the object (objectui#12109) - #12122
Merged
objectstack-fleet[bot] merged 2 commits intoOct 11, 2026
Conversation
…r a caller who cannot read the object (objectui#12109) The sidebar and the nav:menu block prune a type: 'object' navigation entry whose object the caller may not read, before the tree reaches the layout's item guard, through one function (withoutUnreadableObjectEntries). The object list page draws its list toolbar actions only for a caller who may read the object, on the same verdict (mayReadObject). The verdict is usePermissions().can(objectName, 'read'), the permissions the console already loads; an authored requiredPermissions still applies on top. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude <noreply@anthropic.com>
…w lint findings The two useMemo wrappers drew react-hooks/exhaustive-deps and preserve-manual-memoization on the lines they added. A tree with nothing to prune comes back as the array it was, so the memo bought nothing and nothing downstream keys on a pruned tree's identity. Also drops two new explicit anys (an annotation on ObjectView's listActions, a test cast). Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
This was referenced Oct 11, 2026
objectstack-fleet
Bot
deleted the
claude/issue-12109-nav-object-read-gate
branch
October 11, 2026 06:38
This was referenced Oct 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #12109
Clause-②: no
What this changes
By default, a navigation entry bound to an object (
type: 'object', by itsobjectName) is no longer drawn for a caller who may not read that object, and that object's list toolbar actions are no longer drawn on its list page. On HotCRM (objectstack-ai/hotcrm#2058,@objectstack/*17.7.0) a service agent with no read oncrm_opportunitywas shown Opportunities, My Deals and Update Stage, and each one ended on "You don't have access".usePermissions().can(objectName, 'read'): the/auth/me/permissionsanswer the console already holds throughMePermissionsProvider. No request is made per entry (pinned: one permissions fetch for the whole menu). It is the answer the sidebar's permission checker already gave an AUTHOREDrequiredPermissions: ['OBJECT:read'], now applied by default. An authoredrequiredPermissionsstill applies on top, in the layout's item guard (pinned).packages/app-shell/src/utils/navObjectReadGate.ts, is not re-exported by the package entry. It holdsmayReadObject(the verdict) andwithoutUnreadableObjectEntries(prunes a navigation tree at every depth, and hands back the same array when nothing is pruned).UnifiedSidebarprunes every area's tree and the flat tree before the area election, and before the tree reachesNavigationRenderer. So the drawn rows, the derived area election and the Favorites collection all read one tree.nav:menublock (nav-menu-renderer.tsx) prunes through the same function, so the two menus agree. Its docblock says so.ObjectView's schema-driven list toolbar drawsobjectDef.actionsonly whenmayReadObjectholds for the object. The managed-by empty state'spageOffersCreatereads the same gated list, so its copy keeps following the buttons actually drawn.packages/layout/src/NavigationRenderer.tsx: one docblock paragraph onpassesNavItemGuardsthat says what it does not ask and who does. No code moves there.Where it landed, and why not in
passesNavItemGuardsThe card expected the fix in layout's
passesNavItemGuardsand inUnifiedSidebar's guard. Measured onmain5f75cfa:UnifiedSidebarhas no predicate of its own. It builds the host callbacks (checkPerm,checkCap,checkDocTarget) and hands them to layout's singlepassesNavItemGuards, throughNavigationRendererandhasVisibleNavigationItems. The actual second copy of the guard sequence is thenav:menublock: itsrenderItemre-spells the sequence and it rebuilds the same callbacks. That is whynav-menu-renderer.tsxis in this diff.@object-ui/layoutholds no permissions. Itspackage.jsondoes not depend on@object-ui/permissions. It asks its host through callbacks, and none of them answers "may this member read this object":CapabilityCheckerasks whether the RUNTIME has the object. The spec'srequiresObjectdocblock reads "this gates on runtime capability, not user authorization".PermissionCheckerstrings are opaque by contract.Clause-②: norules out.UnifiedSidebaralready prunes Studio's tree that way.Mechanism assumptions, measured
MassUpdateStageAction(src/sales/actions/opportunity.actions.ts:objectName: 'crm_opportunity',locations: ['list_toolbar']). The reader that draws it isObjectView'stoolbarBar(action:baratlist_toolbar). It is not in the navigation tree, so the navigation pruning cannot cover it. The same verdict (mayReadObject) covers it at that reader. In HotCRM'scrm.app.ts, Opportunities istype: 'object'oncrm_opportunity, and My Deals istype: 'object'oncrm_opportunitywithviewName: 'my_open_deals'.MePermissionsProvideraboveDefaultAppContent. The provider renders itsloadingFallback, not its children, until it holds an answer, so the sidebar is not mounted in that window. On a refetch it answers from the map it holds. With no provider,cananswerstrue. The gate callscanexactly as the existingrequiredPermissionschecker does, so neither window changes: a refusal hides an entry, and an unknown hides nothing (pinned: with no provider, every entry is drawn).packages/spec/src/ui/app.zod.tsonorigin/main. OnlyObjectNavItemSchemanames an object in a declared key (objectName).dashboard,reportandpagename their own target,actionnames an action, andcomponent,url,doc,groupandseparatorname no object. Onlytype: 'object'is gated, and an object entry'schildrengo with it, as a gated node's subtree does in the layout. Nothing is guessed from a route string.Pins (new)
utils/__tests__/navObjectReadGate-12109.test.ts: an unreadable entry is dropped, at any depth; a readable one is kept. CONTROL: every non-object type is kept, and the tree comes back as the same array. The verdict is asked forreadon the entry's ownobjectName.layout/__tests__/UnifiedSidebar.objectReadGate-12109.test.tsx, through the REALMePermissionsProviderfed by afetcher:requiredPermissionsstill hides a readable object;views/__tests__/nav-object-read-gate-12109.render.test.tsx: thenav:menublock agrees, area election included.views/ObjectView.listActionsReadGate-12109.test.tsx: the sameMassUpdateStageActionon the same page is drawn for a caller with read and not drawn for the agent. It is one differential: onlyallowReaddiffers.Reverse verification (each leg through
ablation-replace.mjs: anchor hit, on-disk blob change, restore provenblob == HEADwithgit diff HEADempty)Directions were predicted before running. The four suites hold 17 tests.
mayReadObjectanswerstrueUnifiedSidebarimports an identity pruner9495fbb: same)nav:menuskips the prunerObjectViewlist gate forced openM1 to M4 ran on
174f48f. The restructure in9495fbbtouched onlyUnifiedSidebar, so M2 was re-run there. M2, M3 and M4 each redden only their own surface, which holds the three readers apart. The CONTROL and "readable is shown" cases stay green under every leg, as predicted, because they assert presence that a deleted gate also supplies.Gates (head
9495fbb)pnpm exec vitest runover the sidebar, nav,nav:menu, page-block, everyObjectView.*,environment/,app-shellutils/__tests__/andpackages/layout/src/. Result:Test Files 142 passed | 1 skipped (143),Tests 1357 passed | 8 skipped (1365), exit 0.pnpm --filter @object-ui/layout --filter @object-ui/app-shell type-check: exit 0, bothtype-checkscripts echoed. The test project (tsconfig.test.json --listFilesOnly) lists all four new test files. Built afterturbo run build --filter='@object-ui/app-shell^...'.pnpm --filter @object-ui/app-shell build:dist completeness: 1 package(s) complete.pnpm check:eager-closure:Console eager closure is 3170.6 KB gzipped across 290 of 2474 chunks (budget: 3204.6 KB, headroom: 33.9 KB). Base-to-head delta, two consolevite builds in one container (5f75cfaand9495fbb): eager gzip 3246485 → 3246726 bytes (+241), raw +592. Eager chunk count is 290 → 290, total 2474 → 2474. The chunks that moved are ObjectView +38, index +57 and src +146 bytes gzip.check:phantom-deps,check:self-import,check:esm-specifiers,check:new-line-citations,check:control-bytes,check-changeset-presence,check-changeset-no-major,check:changeset-claims,check:pending-changeset-literals,check:test-path-roots, the threecheck:vi-mock-*,check:unreferenced-sources,check:i18n-keys,check-changeset-fixed,check-type-check-coverageandcheck:sdui-registration-pins.check:readme-exports. Reason: a prerequisite was not met (unbuiltplugin-gantt,plugin-map,plugin-markdownandplugin-timeline, which are outside this closure). This diff touches no README.check:unused-deps, because no dependency was added.eslint.config.js: the rule-bearing block isfiles: ['**/*.{ts,tsx}']under the global ignores. The config extendstseslint.configs.recommended, with noparserOptions.projectorprojectService. No rule ineslint-rules/reads the file system or a type checker.eslint --format jsonover the 9 touched.ts/.tsxfiles: 9 results, 0 errors. The per-rule warning counts on the 4 modified sources are identical at base and head (UnifiedSidebar10,ObjectView174,nav-menu-renderer11,NavigationRenderer21). The 5 new files have 0 findings.app-shelldist:index.d.ts/index.js, and the positive controlUnifiedSidebarhas 1;.d.tsreferences the new module;packages/*/src/index.ts,package.json, locale pack orpackages/typesfile changed;t(call was added;check-governed-queue-guard.mjs --testanswersNOT GOVERNEDover all 10 paths.Acceptance notes
These are observations I did not measure at a public door. They are not filed, and no one carries them yet.
CommandPaletteandSearchResultsPageboth list the app'stype: 'object'entries. The palette filters them byvisibleonly, and the search page does not filter them at all. They are the same family as this card, on two surfaces it did not name.withoutUnreadableObjectEntriesis the function either would call.findFirstRouteinconsole/AppContent.tsx) ignores every item guard,requiredPermissionsincluded. An app whose first entry is an unreadable object lands on it. HotCRM's first entry is a dashboard.list_toolbaractions do not ask the read verdict:InterfaceListPagebuttons and the related-list toolbar (RelatedRecordActionsBridge).ObjectViewrow actions (list_item) are not gated. The rows of an unreadable object never load, so nothing draws them.AppSchemaRenderer(layout, for non-console hosts) has no member-read verdict to ask. Such a host gets this default only by pruning, as app-shell does. Giving the layout a callback for it would be aClause-②: yeschange.Deviations
NavigationRenderer.tsx,UnifiedSidebar.tsx, the list-action reader, their tests and the changeset. This diff also touches two files outside that list:nav-menu-renderer.tsx, the second copy of the guard sequence. Its docblock promises it matches the sidebar, and that promise would have gone false.utils/navObjectReadGate.ts, which holds the one predicate.ObjectView.tsx.crm.app.tsandopportunity.actions.tswere read as public files atraw.githubusercontent.com(main).The session for this run is
https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz.Generated by Claude Code