@checkstack/frontend 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,152 @@
1
1
  # @checkstack/frontend
2
2
 
3
+ ## 0.9.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 16f89bd: Disable production source maps by default in the frontend build. Source maps over
8
+ the bundled Monaco / VS Code (`@codingame/*`) editor stack roughly doubled the
9
+ build's time and peak memory and shipped several MB of `.js.map` into the image -
10
+ enough to OOM-thrash a CI runner (the Docker image build hung on the frontend
11
+ build step). They are now off by default; opt in locally with
12
+ `VITE_SOURCEMAP=true bun run --filter '@checkstack/frontend' build` when you need
13
+ to debug the production bundle.
14
+
15
+ Also fixes the `@module-federation/vite` `shared` typing (its published type omits
16
+ `eager` and types `requiredVersion` as `string`, narrower than the MF runtime),
17
+ so `vite.config.ts` no longer reports type errors.
18
+
19
+ - 56e7c75: Hide navigation, actions and links that the current user cannot use, so anonymous
20
+ and read-only users no longer see entries that lead to "Access Denied" or to
21
+ actions the server would reject.
22
+
23
+ - **Sidebar**: a nav entry can now declare a dynamic `nav.isVisible({ accessRules, isAuthenticated })` predicate (in addition to the static `accessRule`). A group whose every entry is filtered out is no longer rendered. The filtering/grouping logic is extracted to a pure, unit-tested helper.
24
+ - **Infrastructure**: its sidebar entry is shown only when the user can READ at least one contributed tab (queue, cache, …), instead of always (it previously had no static rule because tabs are contributed at runtime).
25
+ - **Notification Settings**: hidden from anonymous users - notifications are per-user, so an anonymous visitor can't have any.
26
+ - **Anomaly Mute / Suppress**: the "Mute" / "Mute all" controls (a per-user preference) are hidden from anonymous visitors; the "Suppress" control is gated on `anomalyAccess.feed.manage`. Both were previously always visible.
27
+ - **Dashboard**: the "Open Catalog" actions (which open the manage-only Catalog config page) are hidden from users without `catalogAccess.system.manage`, and the "View catalog" link is gated on `catalogAccess.system.read`.
28
+ - **Dashboard status signals**: the per-system status rows contributed by plugins (`SystemSignalsSlot`) now render as a LINK only when the user can open the target, and as plain text otherwise. `SystemSignal` gains an optional `accessRule`; the healthcheck, anomaly, and dependency fillers set it for their gated targets (check-history / assignments / dependency-map). Signals pointing at ungated pages (incident / maintenance / SLO detail) stay links.
29
+ - **Plugin Manager**: the "Install plugin" button (which opens the install-gated page) is hidden from users with only `plugin` view access.
30
+ - **Satellites**: the page is entirely manage-gated, but its route/sidebar entry was gated on `read`, so read-only users saw the nav item and hit "Access Denied" on click. The route and nav entry now require `satellite.manage`.
31
+
32
+ The `@checkstack/ai-backend` bump is only the regenerated bundled docs index
33
+ (the frontend routing guide gained the `nav.isVisible` section); no code change.
34
+
35
+ **BREAKING (`@checkstack/frontend-api`):** the `AccessApi` interface gains a
36
+ required `useIsAuthenticated()` method. Custom `AccessApi` implementations must
37
+ add it (it returns `{ loading, isAuthenticated }`). The built-in auth
38
+ implementation and the no-auth fallback already do. `NavEntry` also gains an
39
+ optional `isVisible` predicate (purely additive).
40
+
41
+ - 56e7c75: Fix frontend access checks to use FULLY-QUALIFIED access-rule ids, and resolve
42
+ the anonymous role on the frontend.
43
+
44
+ Granted access-rule ids are stored fully-qualified as `{pluginId}.{ruleId}` (e.g.
45
+ `incident.incident.read`) so two plugins defining the same short rule id never
46
+ collide. The frontend, however, was checking the UNqualified id (`incident.read`)
47
+ via `isAccessRuleSatisfied`, so every check failed for any user without the `*`
48
+ (admin) grant - masked in development because dev-auth grants `*`. This silently
49
+ broke ALL non-admin frontend gating (route guards, sidebar entries, and
50
+ `useAccess`-based button/link gating).
51
+
52
+ - **`@checkstack/common`**: `AccessRule` now carries a REQUIRED owning `pluginId`;
53
+ `access()` / `accessPair()` require and stamp it; `isAccessRuleSatisfied`
54
+ qualifies the rule (`{pluginId}.{id}`, plus the manage->read escalation) and
55
+ matches ONLY the qualified form. There is intentionally NO unqualified fallback
56
+ - matching a bare id would let one plugin's grant satisfy another plugin's
57
+ identically-named rule (a cross-plugin privilege-escalation flaw). Every plugin
58
+ that defines access rules now passes its own `pluginId`.
59
+ - **`@checkstack/backend`**: `pluginManager.getAllAccessRules()` no longer strips
60
+ the `pluginId` field (the rule `id` is already fully-qualified for the DB sync).
61
+ - **Route guard** (`@checkstack/frontend` / `@checkstack/frontend-api`) now
62
+ checks the FULL rule object (so it qualifies and escalates), not a bare id.
63
+ - **Anonymous role on the frontend**: the `accessRules` procedure is now
64
+ `public`, returning the configurable anonymous role's grants to unauthenticated
65
+ callers; `useAccessRules` fetches them for guests instead of returning an empty
66
+ set. So anonymous UI now reflects exactly what the anonymous role is allowed -
67
+ which an admin can change (`isPublic` is only the seeded default).
68
+ - Incident / maintenance / SLO detail routes are now read-gated (their read rule
69
+ is an `isPublic` default, so the anonymous role holds it unless an admin
70
+ revokes it); their dashboard status signals carry that rule and render as a
71
+ link only when the viewer may open it.
72
+
73
+ **BREAKING (`@checkstack/common`):** `AccessRule.pluginId` is now REQUIRED, and
74
+ `access()` / `accessPair()` require a `pluginId` option. `isAccessRuleSatisfied`
75
+ matches ONLY the fully-qualified `{pluginId}.{ruleId}` form - the previous
76
+ unqualified fallback is removed, because it was a cross-plugin
77
+ privilege-escalation flaw. Any code constructing an `AccessRule` or calling
78
+ `access()`/`accessPair()` must supply the owning `pluginId`.
79
+
80
+ Verified live against an anonymous caller: read pages resolve (qualified match),
81
+ manage actions are denied, manage->read escalation and `*` still work.
82
+
83
+ - Updated dependencies [0626782]
84
+ - Updated dependencies [56e7c75]
85
+ - Updated dependencies [56e7c75]
86
+ - @checkstack/auth-frontend@0.7.5
87
+ - @checkstack/frontend-api@0.9.0
88
+ - @checkstack/dependency-frontend@0.5.5
89
+ - @checkstack/ui@1.15.1
90
+ - @checkstack/common@0.15.0
91
+ - @checkstack/catalog-frontend@0.11.5
92
+ - @checkstack/announcement-frontend@0.4.5
93
+ - @checkstack/about-frontend@0.3.5
94
+ - @checkstack/command-frontend@0.3.5
95
+ - @checkstack/signal-common@0.2.9
96
+ - @checkstack/signal-frontend@0.2.4
97
+
98
+ ## 0.9.0
99
+
100
+ ### Minor Changes
101
+
102
+ - fb705df: Upgrade React 18 to React 19 across the platform.
103
+
104
+ **BREAKING (runtime frontend plugins):** React is shared as a Module Federation
105
+ singleton, so the host now provides **React 19** to every runtime plugin.
106
+ Frontend plugins built against React 18 must be rebuilt against React 19
107
+ (`react` / `react-dom` `^19`). The scaffold templates and the host/plugin MF
108
+ `requiredVersion` are updated to `^19`. `react` (and now `react-dom`) are pinned
109
+ to a single version across the workspace via syncpack so the singleton can never
110
+ skew (react and react-dom must match exactly).
111
+
112
+ The React 19 removed-API surface was audited - the codebase used only no-arg
113
+ `useRef()` (now `useRef<T | undefined>(undefined)`); no `ReactDOM.render`,
114
+ legacy context, string refs, or function-component `defaultProps`. This also
115
+ clears the `IMPORT_IS_UNDEFINED` build warnings for `React.use` /
116
+ `React.useOptimistic` (react-router 7 feature-detection), which React 19 exports.
117
+
118
+ The downstream `*-frontend` packages (and `@checkstack/infrastructure-common`)
119
+ receive only the mechanical `react` dependency bump (`patch`); the framework
120
+ packages carrying the shared-singleton change are bumped `minor`.
121
+
122
+ ### Patch Changes
123
+
124
+ - 9d8961c: Fix the double-scrolling on the AI chat page (`/ai/chat`). The page sized its
125
+ layout with a fixed `calc(100vh - 220px)` height, which overshot the available
126
+ space when the page subtitle wrapped to two lines - so the whole page scrolled
127
+ on top of the message list's own scroll.
128
+
129
+ `PageLayout` gains an opt-in `fillHeight` prop that fills the viewport via a
130
+ bounded flex height chain (established in the app shell) instead of viewport
131
+ math; the chat page uses it so only the message list scrolls and the page itself
132
+ never does. Normal document-flow pages are unaffected (they still scroll the
133
+ main area as before).
134
+
135
+ - Updated dependencies [9d8961c]
136
+ - Updated dependencies [50123c7]
137
+ - Updated dependencies [fb705df]
138
+ - @checkstack/ui@1.15.0
139
+ - @checkstack/dependency-frontend@0.5.4
140
+ - @checkstack/frontend-api@0.8.0
141
+ - @checkstack/about-frontend@0.3.4
142
+ - @checkstack/announcement-frontend@0.4.4
143
+ - @checkstack/auth-frontend@0.7.4
144
+ - @checkstack/catalog-frontend@0.11.4
145
+ - @checkstack/command-frontend@0.3.4
146
+ - @checkstack/signal-frontend@0.2.3
147
+ - @checkstack/common@0.14.1
148
+ - @checkstack/signal-common@0.2.8
149
+
3
150
  ## 0.8.0
4
151
 
5
152
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/frontend",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "license": "Elastic-2.0",
5
5
  "checkstack": {
6
6
  "type": "frontend"
@@ -20,17 +20,17 @@
20
20
  "lint:code": "eslint . --max-warnings 0"
21
21
  },
22
22
  "dependencies": {
23
- "@checkstack/about-frontend": "0.3.3",
24
- "@checkstack/announcement-frontend": "0.4.3",
25
- "@checkstack/auth-frontend": "0.7.3",
26
- "@checkstack/catalog-frontend": "0.11.3",
27
- "@checkstack/command-frontend": "0.3.3",
28
- "@checkstack/common": "0.14.1",
29
- "@checkstack/dependency-frontend": "0.5.3",
30
- "@checkstack/frontend-api": "0.7.2",
31
- "@checkstack/signal-common": "0.2.8",
32
- "@checkstack/signal-frontend": "0.2.2",
33
- "@checkstack/ui": "1.14.0",
23
+ "@checkstack/about-frontend": "0.3.5",
24
+ "@checkstack/announcement-frontend": "0.4.5",
25
+ "@checkstack/auth-frontend": "0.7.5",
26
+ "@checkstack/catalog-frontend": "0.11.5",
27
+ "@checkstack/command-frontend": "0.3.5",
28
+ "@checkstack/common": "0.15.0",
29
+ "@checkstack/dependency-frontend": "0.5.5",
30
+ "@checkstack/frontend-api": "0.9.0",
31
+ "@checkstack/signal-common": "0.2.9",
32
+ "@checkstack/signal-frontend": "0.2.4",
33
+ "@checkstack/ui": "1.15.1",
34
34
  "@module-federation/runtime": "^2.5",
35
35
  "@module-federation/vite": "^1.16",
36
36
  "@orpc/client": "^1.14.4",
@@ -41,18 +41,18 @@
41
41
  "class-variance-authority": "^0.7.0",
42
42
  "clsx": "^2.1.0",
43
43
  "lucide-react": "^1.17.0",
44
- "react": "^18.3.1",
45
- "react-dom": "^18.2.0",
44
+ "react": "19.2.7",
45
+ "react-dom": "19.2.7",
46
46
  "react-router-dom": "^7.16.0",
47
47
  "tailwind-merge": "^2.2.0",
48
48
  "tailwindcss": "^3.4.1",
49
49
  "tailwindcss-animate": "^1.0.7"
50
50
  },
51
51
  "devDependencies": {
52
- "@checkstack/scripts": "0.5.0",
52
+ "@checkstack/scripts": "0.6.1",
53
53
  "@checkstack/tsconfig": "0.0.7",
54
- "@types/react": "^18.2.64",
55
- "@types/react-dom": "^18.2.21",
54
+ "@types/react": "^19.0.0",
55
+ "@types/react-dom": "^19.0.0",
56
56
  "@vitejs/plugin-react": "^6.0.2",
57
57
  "postcss": "^8.5.15",
58
58
  "vite": "^8.0.16"
package/src/App.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  useNavigate,
8
8
  } from "react-router-dom";
9
9
  import { Menu } from "lucide-react";
10
+ import type { AccessRule } from "@checkstack/common";
10
11
  import {
11
12
  ApiProvider,
12
13
  ApiRegistryBuilder,
@@ -121,15 +122,13 @@ function GlobalShortcuts() {
121
122
 
122
123
  const RouteGuard: React.FC<{
123
124
  children: React.ReactNode;
124
- accessRule?: string;
125
+ accessRule?: AccessRule;
125
126
  }> = ({ children, accessRule }) => {
126
127
  const accessApi = useApi(accessApiRef);
127
- // If there's an access rule requirement, use useAccess with a minimal AccessRule-like object
128
- // the route.accessRule is already the qualified access rule ID string
128
+ // Pass the FULL rule so the check qualifies it (`{pluginId}.{id}`) against the
129
+ // user's granted ids and applies manage->read escalation.
129
130
  const { allowed, loading } = accessRule
130
- ? accessApi.useAccess({ id: accessRule } as Parameters<
131
- typeof accessApi.useAccess
132
- >[0])
131
+ ? accessApi.useAccess(accessRule)
133
132
  : { allowed: true, loading: false };
134
133
 
135
134
  if (loading) {
@@ -207,8 +206,12 @@ function AppContent() {
207
206
  mobileOpen={mobileNavOpen}
208
207
  onMobileOpenChange={setMobileNavOpen}
209
208
  />
210
- <main className="flex-1 min-w-0 overflow-y-auto">
211
- <div className="px-3 py-4 md:p-8 w-full max-w-7xl mx-auto">
209
+ <main className="flex flex-1 min-w-0 flex-col overflow-y-auto">
210
+ {/* `flex-1 min-h-0 flex flex-col` establishes a bounded height
211
+ chain so a page can fill the viewport (PageLayout `fillHeight`)
212
+ and scroll an inner pane instead of the whole page. Tall normal
213
+ pages still overflow into `main`'s scroll (verified). */}
214
+ <div className="flex flex-1 min-h-0 flex-col px-3 py-4 md:p-8 w-full max-w-7xl mx-auto">
212
215
  <Routes>
213
216
  <Route
214
217
  path="/"
@@ -286,7 +289,9 @@ function AppWithApis() {
286
289
  const registryBuilder = new ApiRegistryBuilder()
287
290
  .register(loggerApiRef, new ConsoleLoggerApi())
288
291
  .register(accessApiRef, {
289
- useAccess: () => ({ loading: false, allowed: true }), // Default to allow all if no auth plugin present
292
+ // Default to allow all if no auth plugin present (mirrors useAccess).
293
+ useAccess: () => ({ loading: false, allowed: true }),
294
+ useIsAuthenticated: () => ({ loading: false, isAuthenticated: true }),
290
295
  })
291
296
  .registerFactory(fetchApiRef, (_registry) => {
292
297
  return new CoreFetchApi(baseUrl);
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AccessRule } from "@checkstack/common";
3
+ import { selectNavGroups, type NavLike } from "./Sidebar.logic";
4
+
5
+ const GROUP_ORDER = ["Workspace", "Configuration", "Documentation"] as const;
6
+
7
+ // Rules carry an owning pluginId; checks match the qualified `${pluginId}.${id}`.
8
+ const rule = (id: string): AccessRule =>
9
+ ({ id, resource: id, level: "read", pluginId: "p" }) as unknown as AccessRule;
10
+
11
+ interface Route {
12
+ path: string;
13
+ nav?: NavLike;
14
+ }
15
+
16
+ const route = (path: string, nav?: Partial<NavLike>): Route => ({
17
+ path,
18
+ nav: nav ? { group: "Configuration", label: path, order: 0, ...nav } : undefined,
19
+ });
20
+
21
+ describe("selectNavGroups", () => {
22
+ test("drops routes without nav", () => {
23
+ const groups = selectNavGroups({
24
+ routes: [route("/a"), route("/b", { group: "Workspace" })],
25
+ accessRules: [],
26
+ isAuthenticated: true,
27
+ groupOrder: GROUP_ORDER,
28
+ });
29
+ expect(groups).toHaveLength(1);
30
+ expect(groups[0]?.items.map((r) => r.path)).toEqual(["/b"]);
31
+ });
32
+
33
+ test("hides an entry whose accessRule the user lacks, and drops the now-empty group", () => {
34
+ const groups = selectNavGroups({
35
+ routes: [route("/secret", { group: "Configuration", accessRule: rule("x.manage") })],
36
+ accessRules: [], // anonymous / no grants
37
+ isAuthenticated: true,
38
+ groupOrder: GROUP_ORDER,
39
+ });
40
+ // The only Configuration entry is gated away -> the group is not returned.
41
+ expect(groups).toEqual([]);
42
+ });
43
+
44
+ test("shows an entry whose accessRule the user satisfies", () => {
45
+ const groups = selectNavGroups({
46
+ routes: [route("/ok", { accessRule: rule("x.read") })],
47
+ accessRules: ["p.x.read"],
48
+ isAuthenticated: true,
49
+ groupOrder: GROUP_ORDER,
50
+ });
51
+ expect(groups[0]?.items.map((r) => r.path)).toEqual(["/ok"]);
52
+ });
53
+
54
+ test("isVisible predicate gates the entry (Infrastructure: any readable tab)", () => {
55
+ // Simulate Infrastructure: visible only if the user can read a tab. Here the
56
+ // predicate returns false, so the entry (and its lone group) vanish.
57
+ const infra = route("/infrastructure", {
58
+ isVisible: ({ accessRules }) => accessRules.includes("queue.read"),
59
+ });
60
+ expect(
61
+ selectNavGroups({
62
+ routes: [infra],
63
+ accessRules: [],
64
+ isAuthenticated: true,
65
+ groupOrder: GROUP_ORDER,
66
+ }),
67
+ ).toEqual([]);
68
+ // With the tab grant, it appears.
69
+ expect(
70
+ selectNavGroups({
71
+ routes: [infra],
72
+ accessRules: ["queue.read"],
73
+ isAuthenticated: true,
74
+ groupOrder: GROUP_ORDER,
75
+ })[0]?.items.map((r) => r.path),
76
+ ).toEqual(["/infrastructure"]);
77
+ });
78
+
79
+ test("isVisible receives isAuthenticated (Notification: authenticated only)", () => {
80
+ const notif = route("/notification", {
81
+ isVisible: ({ isAuthenticated }) => isAuthenticated,
82
+ });
83
+ expect(
84
+ selectNavGroups({
85
+ routes: [notif],
86
+ accessRules: [],
87
+ isAuthenticated: false,
88
+ groupOrder: GROUP_ORDER,
89
+ }),
90
+ ).toEqual([]);
91
+ expect(
92
+ selectNavGroups({
93
+ routes: [notif],
94
+ accessRules: [],
95
+ isAuthenticated: true,
96
+ groupOrder: GROUP_ORDER,
97
+ })[0]?.items.map((r) => r.path),
98
+ ).toEqual(["/notification"]);
99
+ });
100
+
101
+ test("both accessRule AND isVisible must pass", () => {
102
+ const r = route("/both", {
103
+ accessRule: rule("x.read"),
104
+ isVisible: ({ isAuthenticated }) => isAuthenticated,
105
+ });
106
+ // Has the rule but not authenticated -> hidden.
107
+ expect(
108
+ selectNavGroups({
109
+ routes: [r],
110
+ accessRules: ["p.x.read"],
111
+ isAuthenticated: false,
112
+ groupOrder: GROUP_ORDER,
113
+ }),
114
+ ).toEqual([]);
115
+ });
116
+
117
+ test("orders groups by groupOrder then alphabetically, items by order then label", () => {
118
+ const groups = selectNavGroups({
119
+ routes: [
120
+ route("/z", { group: "Documentation", order: 0, label: "Z" }),
121
+ route("/w", { group: "Workspace", order: 0, label: "W" }),
122
+ route("/c2", { group: "Configuration", order: 2, label: "C2" }),
123
+ route("/c1", { group: "Configuration", order: 1, label: "C1" }),
124
+ route("/x", { group: "Xtra", order: 0, label: "X" }), // unknown group -> last
125
+ ],
126
+ accessRules: [],
127
+ isAuthenticated: true,
128
+ groupOrder: GROUP_ORDER,
129
+ });
130
+ expect(groups.map((g) => g.group)).toEqual([
131
+ "Workspace",
132
+ "Configuration",
133
+ "Documentation",
134
+ "Xtra",
135
+ ]);
136
+ expect(
137
+ groups.find((g) => g.group === "Configuration")?.items.map((r) => r.path),
138
+ ).toEqual(["/c1", "/c2"]);
139
+ });
140
+ });
@@ -0,0 +1,88 @@
1
+ import { isAccessRuleSatisfied, type AccessRule } from "@checkstack/common";
2
+
3
+ /**
4
+ * The nav-entry fields the sidebar filtering/grouping reads. Kept structural so
5
+ * this logic is pure and unit-testable without the registry or React.
6
+ */
7
+ export interface NavLike {
8
+ group: string;
9
+ label: string;
10
+ order: number;
11
+ /** Static access rule gating visibility (effective rule from the registry). */
12
+ accessRule?: AccessRule;
13
+ /** Dynamic predicate; both this AND `accessRule` must pass to show the entry. */
14
+ isVisible?: (context: {
15
+ accessRules: string[];
16
+ isAuthenticated: boolean;
17
+ }) => boolean;
18
+ }
19
+
20
+ /** One rendered sidebar section: a group label and its visible entries. */
21
+ export interface NavGroupOf<R> {
22
+ group: string;
23
+ items: R[];
24
+ }
25
+
26
+ /**
27
+ * Filter routes to the ones the current user may see, group them by section, and
28
+ * order both groups and items. A route is visible when:
29
+ * - it declares `nav`, AND
30
+ * - its `accessRule` (if any) is satisfied by the user's access rules, AND
31
+ * - its `isVisible` predicate (if any) returns true.
32
+ *
33
+ * Crucially, groups are built ONLY from visible routes, so a section whose every
34
+ * entry is filtered out is not returned at all - e.g. the Infrastructure entry
35
+ * disappears for a user who can read none of its tabs. Pure: no registry, no
36
+ * hooks, no DOM.
37
+ */
38
+ export function selectNavGroups<R extends { nav?: NavLike }>({
39
+ routes,
40
+ accessRules,
41
+ isAuthenticated,
42
+ groupOrder,
43
+ }: {
44
+ routes: R[];
45
+ accessRules: string[];
46
+ isAuthenticated: boolean;
47
+ groupOrder: readonly string[];
48
+ }): Array<NavGroupOf<R & { nav: NavLike }>> {
49
+ const visible = routes.filter((route): route is R & { nav: NavLike } => {
50
+ const nav = route.nav;
51
+ if (nav === undefined) return false;
52
+ if (
53
+ nav.accessRule !== undefined &&
54
+ !isAccessRuleSatisfied(accessRules, nav.accessRule)
55
+ ) {
56
+ return false;
57
+ }
58
+ if (
59
+ nav.isVisible !== undefined &&
60
+ !nav.isVisible({ accessRules, isAuthenticated })
61
+ ) {
62
+ return false;
63
+ }
64
+ return true;
65
+ });
66
+
67
+ const byGroup = new Map<string, Array<R & { nav: NavLike }>>();
68
+ for (const route of visible) {
69
+ const list = byGroup.get(route.nav.group);
70
+ if (list) list.push(route);
71
+ else byGroup.set(route.nav.group, [route]);
72
+ }
73
+
74
+ const orderOf = (group: string): number => {
75
+ const index = groupOrder.indexOf(group);
76
+ return index === -1 ? groupOrder.length : index;
77
+ };
78
+
79
+ return [...byGroup.entries()]
80
+ .toSorted(([a], [b]) => orderOf(a) - orderOf(b) || a.localeCompare(b))
81
+ .map(([group, items]) => ({
82
+ group,
83
+ items: items.toSorted(
84
+ (a, b) =>
85
+ a.nav.order - b.nav.order || a.nav.label.localeCompare(b.nav.label),
86
+ ),
87
+ }));
88
+ }
@@ -2,11 +2,8 @@ import React, { useMemo, useState } from "react";
2
2
  import { NavLink, useLocation } from "react-router-dom";
3
3
  import { pluginRegistry } from "@checkstack/frontend-api";
4
4
  import { useAccessRules } from "@checkstack/auth-frontend";
5
- import {
6
- isAccessRuleSatisfied,
7
- APP_DOC_SLUGS,
8
- docsPath,
9
- } from "@checkstack/common";
5
+ import { APP_DOC_SLUGS, docsPath } from "@checkstack/common";
6
+ import { selectNavGroups } from "./Sidebar.logic";
10
7
  import {
11
8
  Sheet,
12
9
  SheetContent,
@@ -43,42 +40,24 @@ interface NavGroup {
43
40
 
44
41
  /** Build the access-filtered, grouped, ordered nav model from the route registry. */
45
42
  function useNavGroups(): NavGroup[] {
46
- const { accessRules } = useAccessRules();
43
+ const { accessRules, isAuthenticated } = useAccessRules();
47
44
 
48
45
  // getAllRoutes() is recomputed from the registry; plugin load/unload triggers
49
46
  // an App re-render so this stays current. Cheap O(routes) work.
50
47
  const routes = pluginRegistry.getAllRoutes();
51
48
 
52
- return useMemo(() => {
53
- const visible = routes.filter(
54
- (route): route is NavRoute =>
55
- route.nav !== undefined &&
56
- (route.nav.accessRule === undefined ||
57
- isAccessRuleSatisfied(accessRules, route.nav.accessRule)),
58
- );
59
-
60
- const byGroup = new Map<string, NavRoute[]>();
61
- for (const route of visible) {
62
- const list = byGroup.get(route.nav.group);
63
- if (list) list.push(route);
64
- else byGroup.set(route.nav.group, [route]);
65
- }
66
-
67
- const orderOf = (group: string): number => {
68
- const index = GROUP_ORDER.indexOf(group as (typeof GROUP_ORDER)[number]);
69
- return index === -1 ? GROUP_ORDER.length : index;
70
- };
71
-
72
- return [...byGroup.entries()]
73
- .toSorted(([a], [b]) => orderOf(a) - orderOf(b) || a.localeCompare(b))
74
- .map(([group, items]) => ({
75
- group,
76
- items: items.toSorted(
77
- (a, b) =>
78
- a.nav.order - b.nav.order || a.nav.label.localeCompare(b.nav.label),
79
- ),
80
- }));
81
- }, [routes, accessRules]);
49
+ // Pure filtering/grouping (unit-tested in Sidebar.logic.test.ts): access +
50
+ // isVisible gating, with empty groups dropped.
51
+ return useMemo(
52
+ () =>
53
+ selectNavGroups({
54
+ routes,
55
+ accessRules,
56
+ isAuthenticated,
57
+ groupOrder: GROUP_ORDER,
58
+ }) as NavGroup[],
59
+ [routes, accessRules, isAuthenticated],
60
+ );
82
61
  }
83
62
 
84
63
  function loadCollapsedGroups(): Set<string> {
package/vite.config.ts CHANGED
@@ -11,6 +11,21 @@ import { monacoViteConfig } from "../ui/src/vite-monaco";
11
11
  // Monorepo root is 2 levels up from core/frontend
12
12
  const monorepoRoot = path.resolve(__dirname, "../..");
13
13
 
14
+ // The Module Federation share options we actually use. `@module-federation/vite`
15
+ // publishes a `shared` type that is NARROWER than the MF runtime it drives: it
16
+ // omits `eager` and types `requiredVersion` as `string` only. The runtime (and
17
+ // `@module-federation/sdk`'s own `SharedConfig`) supports both `eager: true` and
18
+ // `requiredVersion: false`, which we rely on. We author against this accurate
19
+ // shape and bridge the plugin's outdated param type with a single cast below.
20
+ interface HostShare {
21
+ singleton?: boolean;
22
+ eager?: boolean;
23
+ requiredVersion?: string | false;
24
+ }
25
+ type FederationSharedOption = NonNullable<
26
+ Parameters<typeof federation>[0]["shared"]
27
+ >;
28
+
14
29
  // The Monaco / VS Code editor stack (`@checkstack/ui`'s CodeEditor) needs
15
30
  // `worker.format: "es"` and a `vscode` resolve alias. Both are produced by this
16
31
  // shared helper - also consumed by @checkstack/dev-server - so the app's config
@@ -34,7 +49,7 @@ export default defineConfig(({ command }) => {
34
49
  // below), which fails dep optimization with UNLOADABLE_DEPENDENCY. In dev the
35
50
  // host just bundles @checkstack/ui's editor normally (worker config + vscode
36
51
  // alias are applied unconditionally below), exactly as before this change.
37
- const editorShare =
52
+ const editorShare: Record<string, HostShare> =
38
53
  command === "build"
39
54
  ? {
40
55
  // The Monaco / VS Code editor (the only shared piece of
@@ -58,6 +73,33 @@ export default defineConfig(({ command }) => {
58
73
  }
59
74
  : {};
60
75
 
76
+ // Authored against the accurate `HostShare` shape (eager + requiredVersion:
77
+ // false). Extracted to a const so it is passed as a VARIABLE (not a fresh
78
+ // object literal), letting the single cast below bridge the plugin's narrower
79
+ // published `shared` type without tripping excess-property checks.
80
+ const shared: Record<string, HostShare> = {
81
+ react: { singleton: true, eager: true, requiredVersion: "^19.0.0" },
82
+ "react-dom": { singleton: true, eager: true, requiredVersion: "^19.0.0" },
83
+ "react-router-dom": {
84
+ singleton: true,
85
+ eager: true,
86
+ requiredVersion: "^7.0.0",
87
+ },
88
+ "@tanstack/react-query": {
89
+ singleton: true,
90
+ eager: true,
91
+ requiredVersion: "^5.0.0",
92
+ },
93
+ // First-party, version-locked to the host: skip version negotiation
94
+ // (its dep range is `workspace:*`, not a semver range).
95
+ "@checkstack/frontend-api": {
96
+ singleton: true,
97
+ eager: true,
98
+ requiredVersion: false,
99
+ },
100
+ ...editorShare,
101
+ };
102
+
61
103
  return {
62
104
  // Tell Vite to look for .env files in monorepo root
63
105
  envDir: monorepoRoot,
@@ -81,32 +123,8 @@ export default defineConfig(({ command }) => {
81
123
  federation({
82
124
  name: "checkstack_host",
83
125
  remotes: {},
84
- shared: {
85
- react: { singleton: true, eager: true, requiredVersion: "^18.0.0" },
86
- "react-dom": {
87
- singleton: true,
88
- eager: true,
89
- requiredVersion: "^18.0.0",
90
- },
91
- "react-router-dom": {
92
- singleton: true,
93
- eager: true,
94
- requiredVersion: "^7.0.0",
95
- },
96
- "@tanstack/react-query": {
97
- singleton: true,
98
- eager: true,
99
- requiredVersion: "^5.0.0",
100
- },
101
- // First-party, version-locked to the host: skip version negotiation
102
- // (its dep range is `workspace:*`, not a semver range).
103
- "@checkstack/frontend-api": {
104
- singleton: true,
105
- eager: true,
106
- requiredVersion: false,
107
- },
108
- ...editorShare,
109
- },
126
+ // Cast bridges the plugin's outdated `shared` type (see HostShare).
127
+ shared: shared as FederationSharedOption,
110
128
  }),
111
129
  ],
112
130
  // The @typefox/monaco-editor-react + @codingame/monaco-vscode-* stack
@@ -165,8 +183,12 @@ export default defineConfig(({ command }) => {
165
183
  build: {
166
184
  // Use esnext to support top-level await and modern ES features
167
185
  target: "esnext",
168
- // Generate sourcemaps for production debugging
169
- sourcemap: true,
186
+ // Source maps over the Monaco / VS Code (`@codingame/*`) stack roughly
187
+ // DOUBLE the build's time and peak memory and ship ~MBs of `.js.map` into
188
+ // the image - enough to OOM-thrash a CI runner (the Docker build hung
189
+ // here). Off by default; opt in locally with `VITE_SOURCEMAP=true` when
190
+ // you need to debug the production bundle.
191
+ sourcemap: process.env.VITE_SOURCEMAP === "true",
170
192
  // Monaco stays off the initial load via the `React.lazy(CodeEditor)`
171
193
  // boundary (see CodeEditor.tsx) — verified preserved under Module
172
194
  // Federation's automatic code-splitting. We do NOT hand-group chunks: