Skip to content

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 into
mainfrom
claude/issue-12109-nav-object-read-gate
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-12109-nav-object-read-gate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #12109

Clause-②: no

What this changes

By default, a navigation entry bound to an object (type: 'object', by its objectName) 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 on crm_opportunity was shown Opportunities, My Deals and Update Stage, and each one ended on "You don't have access".

  • The verdict is usePermissions().can(objectName, 'read'): the /auth/me/permissions answer the console already holds through MePermissionsProvider. 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 AUTHORED requiredPermissions: ['OBJECT:read'], now applied by default. An authored requiredPermissions still applies on top, in the layout's item guard (pinned).
  • One predicate, three readers. A new internal module, packages/app-shell/src/utils/navObjectReadGate.ts, is not re-exported by the package entry. It holds mayReadObject (the verdict) and withoutUnreadableObjectEntries (prunes a navigation tree at every depth, and hands back the same array when nothing is pruned).
    • UnifiedSidebar prunes every area's tree and the flat tree before the area election, and before the tree reaches NavigationRenderer. So the drawn rows, the derived area election and the Favorites collection all read one tree.
    • The nav:menu block (nav-menu-renderer.tsx) prunes through the same function, so the two menus agree. Its docblock says so.
    • ObjectView's schema-driven list toolbar draws objectDef.actions only when mayReadObject holds for the object. The managed-by empty state's pageOffersCreate reads the same gated list, so its copy keeps following the buttons actually drawn.
  • packages/layout/src/NavigationRenderer.tsx: one docblock paragraph on passesNavItemGuards that says what it does not ask and who does. No code moves there.

Where it landed, and why not in passesNavItemGuards

The card expected the fix in layout's passesNavItemGuards and in UnifiedSidebar's guard. Measured on main 5f75cfa:

  • They are not two copies of one rule. UnifiedSidebar has no predicate of its own. It builds the host callbacks (checkPerm, checkCap, checkDocTarget) and hands them to layout's single passesNavItemGuards, through NavigationRenderer and hasVisibleNavigationItems. The actual second copy of the guard sequence is the nav:menu block: its renderItem re-spells the sequence and it rebuilds the same callbacks. That is why nav-menu-renderer.tsx is in this diff.
  • @object-ui/layout holds no permissions. Its package.json does not depend on @object-ui/permissions. It asks its host through callbacks, and none of them answers "may this member read this object":
    • CapabilityChecker asks whether the RUNTIME has the object. The spec's requiresObject docblock reads "this gates on runtime capability, not user authorization".
    • PermissionChecker strings are opaque by contract.
    • Asking either one from layout would change the meaning of a published callback. Adding a callback or an option would be a new prop, which Clause-②: no rules out.
    • So the host prunes the tree it hands the layout. UnifiedSidebar already prunes Studio's tree that way.

Mechanism assumptions, measured

  1. The two guards. See above. The predicate stays in one place for the layout's three paths, and the read gate is one function for the two app-shell menus.
  2. "Update Stage" is not a navigation entry. In HotCRM it is MassUpdateStageAction (src/sales/actions/opportunity.actions.ts: objectName: 'crm_opportunity', locations: ['list_toolbar']). The reader that draws it is ObjectView's toolbarBar (action:bar at list_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's crm.app.ts, Opportunities is type: 'object' on crm_opportunity, and My Deals is type: 'object' on crm_opportunity with viewName: 'my_open_deals'.
  3. Unloaded permissions. The console mounts MePermissionsProvider above DefaultAppContent. The provider renders its loadingFallback, 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, can answers true. The gate calls can exactly as the existing requiredPermissions checker does, so neither window changes: a refusal hides an entry, and an unknown hides nothing (pinned: with no provider, every entry is drawn).
  4. Which entries are bound to an object. I read objectstack packages/spec/src/ui/app.zod.ts on origin/main. Only ObjectNavItemSchema names an object in a declared key (objectName). dashboard, report and page name their own target, action names an action, and component, url, doc, group and separator name no object. Only type: 'object' is gated, and an object entry's children go 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 for read on the entry's own objectName.
  • layout/__tests__/UnifiedSidebar.objectReadGate-12109.test.tsx, through the REAL MePermissionsProvider fed by a fetcher:
    • the agent does not see Opportunities or My Deals, and the readable Accounts is shown;
    • a caller with read sees both;
    • CONTROL: the dashboard and URL entries are unchanged;
    • an authored requiredPermissions still hides a readable object;
    • one permissions fetch serves the whole menu;
    • an area whose only entries are unreadable is not offered and is not elected;
    • with no provider, nothing is hidden.
  • views/__tests__/nav-object-read-gate-12109.render.test.tsx: the nav:menu block agrees, area election included.
  • views/ObjectView.listActionsReadGate-12109.test.tsx: the same MassUpdateStageAction on the same page is drawn for a caller with read and not drawn for the agent. It is one differential: only allowRead differs.

Reverse verification (each leg through ablation-replace.mjs: anchor hit, on-disk blob change, restore proven blob == HEAD with git diff HEAD empty)

Directions were predicted before running. The four suites hold 17 tests.

leg mutation predicted red measured
M1 mayReadObject answers true 8 (3 unit, 2 sidebar, 2 nav:menu, 1 list toolbar) 8 failed, 9 passed
M2 UnifiedSidebar imports an identity pruner the 2 sidebar refusal cases only 2 failed, 15 passed (re-run on 9495fbb: same)
M3 nav:menu skips the pruner the 2 nav:menu refusal cases only 2 failed, 15 passed
M4 ObjectView list gate forced open the 1 list-toolbar refusal case 1 failed, 16 passed

M1 to M4 ran on 174f48f. The restructure in 9495fbb touched only UnifiedSidebar, 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)

  • Tests: pnpm exec vitest run over the sidebar, nav, nav:menu, page-block, every ObjectView.*, environment/, app-shell utils/__tests__/ and packages/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, both type-check scripts echoed. The test project (tsconfig.test.json --listFilesOnly) lists all four new test files. Built after turbo 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 console vite builds in one container (5f75cfa and 9495fbb): 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.
  • Exit 0, verdict line green: 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 three check:vi-mock-*, check:unreferenced-sources, check:i18n-keys, check-changeset-fixed, check-type-check-coverage and check:sdui-registration-pins.
  • NOT MEASURED: check:readme-exports. Reason: a prerequisite was not met (unbuilt plugin-gantt, plugin-map, plugin-markdown and plugin-timeline, which are outside this closure). This diff touches no README.
  • Not needed: check:unused-deps, because no dependency was added.
  • Lint, narrowed, with three proofs:
    • Population, from eslint.config.js: the rule-bearing block is files: ['**/*.{ts,tsx}'] under the global ignores. The config extends tseslint.configs.recommended, with no parserOptions.project or projectService. No rule in eslint-rules/ reads the file system or a type checker.
    • Count, from eslint --format json over the 9 touched .ts / .tsx files: 9 results, 0 errors. The per-rule warning counts on the 4 modified sources are identical at base and head (UnifiedSidebar 10, ObjectView 174, nav-menu-renderer 11, NavigationRenderer 21). The 5 new files have 0 findings.
    • Invariance: linting is not type-aware, so this diff cannot move the verdict on any untouched file.
  • Clause-② holds, measured on the built app-shell dist:
    • the three new names have 0 hits in index.d.ts / index.js, and the positive control UnifiedSidebar has 1;
    • no .d.ts references the new module;
    • no packages/*/src/index.ts, package.json, locale pack or packages/types file changed;
    • no t( call was added;
    • layout's diff is comment lines only.
  • Governed surface: check-governed-queue-guard.mjs --test answers NOT GOVERNED over 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.

  • CommandPalette and SearchResultsPage both list the app's type: 'object' entries. The palette filters them by visible only, 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. withoutUnreadableObjectEntries is the function either would call.
  • The app landing (findFirstRoute in console/AppContent.tsx) ignores every item guard, requiredPermissions included. An app whose first entry is an unreadable object lands on it. HotCRM's first entry is a dashboard.
  • Other readers of a child object's list_toolbar actions do not ask the read verdict: InterfaceListPage buttons and the related-list toolbar (RelatedRecordActionsBridge).
  • ObjectView row 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 a Clause-②: yes change.

Deviations

The session for this run is https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz.


Generated by Claude Code

…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>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 290 chunks) 3171.4 KB 3204.6 KB
Main entry chunk (gzip) 74.1 KB 350 KB
Entry file index-D4krHh_m.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 19.75KB 7.29KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 586.66KB 141.23KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 240.39KB 67.09KB
fields (index.js) 269.55KB 68.24KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 54.31KB 15.86KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.52KB 47.99KB
plugin-dashboard (index.js) 144.20KB 38.95KB
plugin-designer (index.js) 233.53KB 49.80KB
plugin-detail (index.js) 249.33KB 65.74KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 177.59KB 46.00KB
plugin-gantt (index.js) 179.17KB 45.07KB
plugin-grid (index.js) 249.52KB 69.17KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 122.77KB 31.19KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 06:20
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 06:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 5a65f7d Oct 11, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-12109-nav-object-read-gate branch October 11, 2026 06:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

navigation: an object entry, and its object-backed list actions, show to a caller who cannot read the object, each ending in "You don't have access"

2 participants