@arcote.tech/platform 0.4.6 → 0.4.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/platform",
3
3
  "type": "module",
4
- "version": "0.4.6",
4
+ "version": "0.4.8",
5
5
  "private": false,
6
6
  "author": "Przemysław Krasiński [arcote.tech]",
7
7
  "description": "Arc Platform — module system, router, layout, theme, i18n, platform app shell",
@@ -18,11 +18,11 @@
18
18
  "type-check": "tsc --noEmit"
19
19
  },
20
20
  "dependencies": {
21
- "@arcote.tech/arc-ds": "^0.4.6",
22
- "@arcote.tech/arc-react": "^0.4.6"
21
+ "@arcote.tech/arc-ds": "^0.4.8",
22
+ "@arcote.tech/arc-react": "^0.4.8"
23
23
  },
24
24
  "peerDependencies": {
25
- "@arcote.tech/arc": "^0.4.6",
25
+ "@arcote.tech/arc": "^0.4.8",
26
26
  "@lingui/core": "^5.0.0",
27
27
  "@lingui/react": "^5.0.0",
28
28
  "framer-motion": "^12.0.0",
package/src/arc.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  BuiltModule,
9
9
  ContextElementFragment,
10
10
  ExtractPages,
11
+ ModuleAccessRule,
11
12
  PageFragment,
12
13
  PageOptions,
13
14
  SlotFragment,
@@ -66,6 +67,8 @@ export function page<TPath extends string>(
66
67
  label: options.label,
67
68
  tooltip: options.tooltip,
68
69
  children: options.children,
70
+ ordering: { order: options.order ?? 0, after: options.after, before: options.before },
71
+ nav: options.nav,
69
72
  };
70
73
  }
71
74
 
@@ -148,9 +151,22 @@ class ModuleBuilder<
148
151
  private _pages: Record<string, string> | undefined;
149
152
  private _ctx: { elements: readonly ArcContextElementAny[] } | undefined;
150
153
  private _scope: string | undefined;
154
+ private _protectedBy: ModuleAccessRule[] = [];
151
155
 
152
156
  constructor(readonly name: string) {}
153
157
 
158
+ /**
159
+ * Restrict module loading to users holding the given token.
160
+ * Optional check function for granular access control.
161
+ */
162
+ protectedBy(
163
+ token: { name: string },
164
+ check?: (tokenInstance: any) => boolean | Promise<boolean>,
165
+ ): this {
166
+ this._protectedBy.push({ token, check });
167
+ return this;
168
+ }
169
+
154
170
  /**
155
171
  * Register the module's ArcContext and scope name.
156
172
  * Context element fragments are auto-injected in build().
@@ -225,12 +241,14 @@ class ModuleBuilder<
225
241
 
226
242
  const mod: Record<string, unknown> = {
227
243
  id: moduleId,
244
+ name: this.name,
228
245
  fragments: allFragments,
229
246
  publicFragments: this._public,
230
247
  pages: Object.freeze(this._pages ?? {}),
231
248
  };
232
249
  if (this._ctx !== undefined) mod.context = this._ctx;
233
250
  if (this._scope !== undefined) mod.scope = this._scope;
251
+ if (this._protectedBy.length > 0) mod.access = { rules: this._protectedBy };
234
252
  if (domain) Object.assign(mod, domain);
235
253
 
236
254
  registerModule(mod as unknown as ArcModule);
@@ -285,12 +303,13 @@ export function arc(
285
303
  // Context registration: factory returned an ArcContext
286
304
  if (isArcContext(result)) {
287
305
  setContext(result);
288
- return { id: moduleId, fragments: [], publicFragments: [] };
306
+ return { id: moduleId, name: moduleId, fragments: [], publicFragments: [] };
289
307
  }
290
308
 
291
309
  const publicFragments: readonly ArcFragment[] = Array.isArray(result) ? result : [];
292
310
  const mod: ArcModule = {
293
311
  id: moduleId,
312
+ name: moduleId,
294
313
  fragments: allFragments,
295
314
  publicFragments,
296
315
  };
package/src/index.ts CHANGED
@@ -7,9 +7,13 @@ export { arc } from "./arc";
7
7
  export {
8
8
  clearModules,
9
9
  clearRegistry,
10
+ forceRegisterModule,
10
11
  getAllFragments,
12
+ getAllModuleAccess,
11
13
  getAllModules,
14
+ getAllRegisteredModules,
12
15
  getContext,
16
+ getModuleAccess,
13
17
  getContextElementFragments,
14
18
  getDefaultLayout,
15
19
  getModule,
@@ -60,6 +64,7 @@ export { PageRouter } from "./layout/page-router";
60
64
  export { PageSubNavShell } from "./layout/page-sub-nav-shell";
61
65
  export { SlotRenderer } from "./layout/slot-renderer";
62
66
  export type { SlotRendererProps } from "./layout/slot-renderer";
67
+ export { usePageFragments } from "./layout/use-page-fragments";
63
68
  export { useSlotFragments } from "./layout/use-slot-fragments";
64
69
 
65
70
  // Router
@@ -86,7 +91,7 @@ export type { LocaleProviderProps } from "./locale";
86
91
  export { useTitle } from "./hooks/use-title";
87
92
 
88
93
  // Platform
89
- export { loadModules, reloadModules, useModuleLoader } from "./module-loader";
94
+ export { loadModules, reloadModules, syncModules, useModuleLoader } from "./module-loader";
90
95
  export type { ModuleLoaderState, ModuleManifest } from "./module-loader";
91
96
  export { PlatformApp } from "./platform-app";
92
97
  export type { PlatformAppProps } from "./platform-app";
@@ -106,6 +111,9 @@ export type {
106
111
  ArcModule,
107
112
  BuiltModule,
108
113
  ContextElementFragment,
114
+ ModuleAccess,
115
+ ModuleAccessRule,
116
+ ModuleDescriptor,
109
117
  ExtractPages,
110
118
  FragmentOrdering,
111
119
  PageFragment,
@@ -1,6 +1,7 @@
1
1
  import { AnimatePresence, motion } from "framer-motion";
2
2
  import { Suspense, useEffect } from "react";
3
- import { getDefaultLayout, getPageFragments } from "../registry";
3
+ import { getDefaultLayout } from "../registry";
4
+ import { usePageFragments } from "./use-page-fragments";
4
5
  import { useArcNavigate, useArcRoute, matchRoutePath, hasRouteParams } from "../router";
5
6
  import { useArcRouter } from "@arcote.tech/arc-ds";
6
7
  import type { PageFragment } from "../types";
@@ -11,8 +12,7 @@ type MatchResult = {
11
12
  params: Record<string, string>;
12
13
  };
13
14
 
14
- function matchPage(path: string): MatchResult | undefined {
15
- const pages = getPageFragments();
15
+ function matchPage(path: string, pages: PageFragment[]): MatchResult | undefined {
16
16
 
17
17
  // 1. Exact match (non-parent pages only)
18
18
  const exact = pages.find((p) => p.path === path && !p.children?.length);
@@ -64,7 +64,8 @@ export function PageRouter() {
64
64
  const route = useArcRoute();
65
65
  const navigate = useArcNavigate();
66
66
  const router = useArcRouter();
67
- const match = matchPage(route);
67
+ const pages = usePageFragments();
68
+ const match = matchPage(route, pages);
68
69
  const DefaultLayout = getDefaultLayout();
69
70
 
70
71
  // Sync matched params to router context
@@ -80,6 +81,14 @@ export function PageRouter() {
80
81
  }
81
82
  }, [route, match?.params]);
82
83
 
84
+ // Redirect to first available page when current route is no longer registered
85
+ useEffect(() => {
86
+ if (!match && pages.length > 0 && route !== "/") {
87
+ const firstNav = pages.find((p) => p.icon && p.label && !p.path.includes("/:"));
88
+ navigate(firstNav?.path ?? "/");
89
+ }
90
+ }, [match, pages, route, navigate]);
91
+
83
92
  if (!match) {
84
93
  if (DefaultLayout) return <DefaultLayout>{null}</DefaultLayout>;
85
94
  return null;
@@ -0,0 +1,20 @@
1
+ import { useRef, useSyncExternalStore } from "react";
2
+ import { getPageFragments, subscribe } from "../registry";
3
+ import type { PageFragment } from "../types";
4
+
5
+ export function usePageFragments(): PageFragment[] {
6
+ const cacheRef = useRef<PageFragment[]>([]);
7
+
8
+ const getSnapshot = () => {
9
+ const next = getPageFragments();
10
+ const prev = cacheRef.current;
11
+
12
+ const changed =
13
+ next.length !== prev.length || next.some((f, i) => f.id !== prev[i]?.id);
14
+
15
+ if (changed) cacheRef.current = next;
16
+ return cacheRef.current;
17
+ };
18
+
19
+ return useSyncExternalStore(subscribe, getSnapshot);
20
+ }
@@ -1,27 +1,75 @@
1
- import { clearModules, getContext } from "./registry";
1
+ import { clearModules, getAllRegisteredModules, getContext, setActiveModules } from "./registry";
2
+ import type { ModuleDescriptor } from "./types";
2
3
  import { useCallback, useEffect, useRef, useState } from "react";
3
4
 
4
5
  export interface ModuleManifest {
5
- modules: string[];
6
+ modules: ModuleDescriptor[];
6
7
  buildTime: string;
7
8
  }
8
9
 
9
10
  export type ModuleLoaderState = "loading" | "ready" | "error";
10
11
 
11
- /**
12
- * Load all modules from manifest. Each module auto-registers via arc().
13
- */
14
- export async function loadModules(baseUrl: string): Promise<ModuleManifest> {
15
- const res = await fetch(`${baseUrl}/api/modules`);
12
+ function moduleUrl(baseUrl: string, mod: ModuleDescriptor, bust?: string): string {
13
+ const base = mod.url ?? `${baseUrl}/modules/${mod.file}`;
14
+ return bust ? `${base}${base.includes("?") ? "&" : "?"}t=${bust}` : base;
15
+ }
16
+
17
+ /** Read all persisted arc tokens from localStorage */
18
+ function getAllPersistedTokens(): Record<string, string> {
19
+ if (typeof localStorage === "undefined") return {};
20
+ const tokens: Record<string, string> = {};
21
+ for (let i = 0; i < localStorage.length; i++) {
22
+ const key = localStorage.key(i);
23
+ if (key?.startsWith("arc:token:")) {
24
+ const raw = localStorage.getItem(key);
25
+ if (raw) tokens[key.slice(10)] = raw;
26
+ }
27
+ }
28
+ return tokens;
29
+ }
30
+
31
+ /** Build X-Arc-Tokens header value from token map */
32
+ function buildTokensHeader(tokens: Record<string, string>): string {
33
+ return Object.entries(tokens)
34
+ .map(([scope, jwt]) => `${scope}:${jwt}`)
35
+ .join(",");
36
+ }
37
+
38
+ function buildHeaders(tokens?: Record<string, string>): HeadersInit {
39
+ const resolved = tokens ?? getAllPersistedTokens();
40
+ const headers: HeadersInit = {};
41
+ const tokensHeader = buildTokensHeader(resolved);
42
+ if (tokensHeader) headers["X-Arc-Tokens"] = tokensHeader;
43
+ // Also send first token as Authorization for backwards compatibility
44
+ const firstToken = Object.values(resolved)[0];
45
+ if (firstToken) headers["Authorization"] = `Bearer ${firstToken}`;
46
+ return headers;
47
+ }
48
+
49
+ /** Fetch manifest from server */
50
+ async function fetchManifest(
51
+ baseUrl: string,
52
+ tokens?: Record<string, string>,
53
+ ): Promise<ModuleManifest> {
54
+ const headers = buildHeaders(tokens);
55
+ const res = await fetch(`${baseUrl}/api/modules`, { headers });
16
56
  if (!res.ok)
17
57
  throw new Error(`Failed to fetch module manifest: ${res.status}`);
58
+ return res.json();
59
+ }
18
60
 
19
- const manifest: ModuleManifest = await res.json();
61
+ /**
62
+ * Load all modules from manifest. Each module auto-registers via module().build().
63
+ */
64
+ export async function loadModules(
65
+ baseUrl: string,
66
+ tokens?: Record<string, string>,
67
+ ): Promise<ModuleManifest> {
68
+ const manifest = await fetchManifest(baseUrl, tokens);
20
69
 
21
- // Import all modules — each calls arc() which auto-registers
22
70
  await Promise.all(
23
71
  manifest.modules.map(
24
- (mod) => import(/* @vite-ignore */ `${baseUrl}/modules/${mod}`),
72
+ (mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod)),
25
73
  ),
26
74
  );
27
75
 
@@ -29,21 +77,52 @@ export async function loadModules(baseUrl: string): Promise<ModuleManifest> {
29
77
  }
30
78
 
31
79
  /**
32
- * Reload modules — clear registry, re-import with cache bust.
80
+ * Sync modules with server import any NEW modules, then set active filter.
81
+ * Modules are never unregistered (ES module cache prevents re-execution).
82
+ * Instead, visibility is controlled via an active set.
33
83
  */
34
- export async function reloadModules(baseUrl: string): Promise<ModuleManifest> {
35
- clearModules();
84
+ export async function syncModules(
85
+ baseUrl: string,
86
+ tokens?: Record<string, string>,
87
+ ): Promise<ModuleManifest> {
88
+ const manifest = await fetchManifest(baseUrl, tokens);
36
89
 
37
- const res = await fetch(`${baseUrl}/api/modules`);
38
- if (!res.ok)
39
- throw new Error(`Failed to fetch module manifest: ${res.status}`);
90
+ const manifestNames = new Set(manifest.modules.map((m) => m.name));
91
+ // Check against ALL registered modules (not just active ones)
92
+ const registeredNames = new Set(getAllRegisteredModules().map((m) => m.name));
93
+
94
+ // Import only truly new modules (never imported before)
95
+ const newModules = manifest.modules.filter((m) => !registeredNames.has(m.name));
96
+
97
+ if (newModules.length > 0) {
98
+ await Promise.all(
99
+ newModules.map((mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod))),
100
+ );
101
+ }
102
+
103
+ // Set active filter — only modules in the manifest are visible
104
+ setActiveModules(manifestNames);
105
+
106
+ return manifest;
107
+ }
40
108
 
41
- const manifest: ModuleManifest = await res.json();
42
- const bust = `?t=${Date.now()}`;
109
+ /**
110
+ * Full reload — clear all modules, re-import with cache bust.
111
+ * Used for dev-mode file changes (code actually changed on disk).
112
+ */
113
+ export async function reloadModules(
114
+ baseUrl: string,
115
+ tokens?: Record<string, string>,
116
+ ): Promise<ModuleManifest> {
117
+ const manifest = await fetchManifest(baseUrl, tokens);
118
+ const bust = String(Date.now());
119
+
120
+ // Clear all modules — fresh code will re-register them
121
+ clearModules();
43
122
 
44
123
  await Promise.all(
45
124
  manifest.modules.map(
46
- (mod) => import(/* @vite-ignore */ `${baseUrl}/modules/${mod}${bust}`),
125
+ (mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod, bust)),
47
126
  ),
48
127
  );
49
128
 
@@ -52,18 +131,21 @@ export async function reloadModules(baseUrl: string): Promise<ModuleManifest> {
52
131
 
53
132
  /**
54
133
  * Hook: loads modules on mount, provides state + context.
134
+ * Syncs modules when tokens change (login/logout/workspace switch).
55
135
  */
56
136
  export function useModuleLoader(
57
137
  baseUrl: string,
58
- options: { skip?: boolean } = {},
138
+ options: { skip?: boolean; token?: string | null } = {},
59
139
  ) {
60
140
  const [state, setState] = useState<ModuleLoaderState>(
61
141
  options.skip ? "ready" : "loading",
62
142
  );
63
143
  const [error, setError] = useState<Error | null>(null);
64
144
  const loaded = useRef(false);
145
+ const [tokenVersion, setTokenVersion] = useState(0);
65
146
 
66
- const reload = useCallback(async () => {
147
+ // Full reload for dev-mode file changes (re-import all JS)
148
+ const fullReload = useCallback(async () => {
67
149
  try {
68
150
  await reloadModules(baseUrl);
69
151
  setState("ready");
@@ -73,6 +155,18 @@ export function useModuleLoader(
73
155
  }
74
156
  }, [baseUrl]);
75
157
 
158
+ // Sync — for token changes (only fetch manifest, import new, remove stale)
159
+ const sync = useCallback(async () => {
160
+ try {
161
+ await syncModules(baseUrl);
162
+ setState("ready");
163
+ } catch (e) {
164
+ setError(e instanceof Error ? e : new Error(String(e)));
165
+ setState("error");
166
+ }
167
+ }, [baseUrl]);
168
+
169
+ // Initial load
76
170
  useEffect(() => {
77
171
  if (options.skip || loaded.current) return;
78
172
  loaded.current = true;
@@ -84,19 +178,55 @@ export function useModuleLoader(
84
178
  setState("error");
85
179
  });
86
180
 
87
- // Listen for SSE reload events from CLI
181
+ // Listen for SSE reload events from CLI (code changed on disk → full reload)
88
182
  const evtSource = new EventSource(`${baseUrl}/api/reload-stream`);
89
183
  evtSource.onmessage = (event) => {
90
184
  if (event.data === "connected") return;
91
185
  console.log("[arc] Modules changed, reloading...");
92
- reload();
186
+ fullReload();
93
187
  };
94
188
  evtSource.onerror = () => {
95
189
  // SSE disconnected — will auto-reconnect
96
190
  };
97
191
 
98
192
  return () => evtSource.close();
99
- }, [baseUrl, reload, options.skip]);
193
+ }, [baseUrl, fullReload, options.skip]);
194
+
195
+ // Listen for token changes → sync (not full reload)
196
+ useEffect(() => {
197
+ if (typeof window === "undefined") return;
198
+
199
+ const onTokenChange = () => setTokenVersion((v) => v + 1);
200
+ window.addEventListener("arc:token-change", onTokenChange);
201
+
202
+ const onStorage = (e: StorageEvent) => {
203
+ if (e.key?.startsWith("arc:token:")) {
204
+ setTokenVersion((v) => v + 1);
205
+ }
206
+ };
207
+ window.addEventListener("storage", onStorage);
208
+
209
+ return () => {
210
+ window.removeEventListener("arc:token-change", onTokenChange);
211
+ window.removeEventListener("storage", onStorage);
212
+ };
213
+ }, []);
214
+
215
+ // Token changed → sync modules (import new, remove stale, don't re-import existing)
216
+ useEffect(() => {
217
+ if (!loaded.current) return;
218
+ if (tokenVersion === 0) return;
219
+ sync();
220
+ }, [tokenVersion, sync]);
221
+
222
+ // Legacy: sync when explicit token prop changes
223
+ const prevToken = useRef(options.token);
224
+ useEffect(() => {
225
+ if (!loaded.current) return;
226
+ if (prevToken.current === options.token) return;
227
+ prevToken.current = options.token;
228
+ sync();
229
+ }, [options.token, sync]);
100
230
 
101
- return { state, error, context: getContext(), reload };
231
+ return { state, error, context: getContext(), reload: fullReload };
102
232
  }
@@ -1,6 +1,6 @@
1
1
  import type { ArcContextAny } from "@arcote.tech/arc";
2
2
  import type { DSVariantOverrides } from "@arcote.tech/arc-ds";
3
- import { DesignSystemProvider } from "@arcote.tech/arc-ds";
3
+ import { DesignSystemProvider, TooltipProvider } from "@arcote.tech/arc-ds";
4
4
  import { reactModel } from "@arcote.tech/arc-react";
5
5
  import { Layout } from "@arcote.tech/arc-ds";
6
6
  import { ArcLayoutProvider } from "./layout/layout-provider";
@@ -13,13 +13,10 @@ import {
13
13
  setDefaultLayout,
14
14
  } from "./registry";
15
15
  import { ThemeProvider } from "./theme";
16
- import { Tooltip as RadixTooltip } from "radix-ui";
17
- import { useEffect, useMemo, useState } from "react";
16
+ import { useEffect, useMemo, useRef, useState } from "react";
18
17
  import { useModuleLoader } from "./module-loader";
19
18
  import { PlatformModelProvider } from "./platform-context";
20
19
 
21
- const TooltipProvider = RadixTooltip.Provider;
22
-
23
20
  export interface PlatformAppProps {
24
21
  apiUrl?: string;
25
22
  wsUrl?: string;
@@ -179,7 +176,16 @@ export function PlatformApp({
179
176
  // If context is pre-loaded (modules imported statically in main.tsx),
180
177
  // skip dynamic module loading entirely.
181
178
  const loader = useModuleLoader(apiUrl, { skip: !!preloadedContext });
182
- const context = preloadedContext ?? loader.context ?? getContext();
179
+
180
+ // Stabilize context reference — once set, don't change it.
181
+ // New context elements from synced modules merge into the global registry
182
+ // but we must NOT recreate the model (would remount the entire tree).
183
+ const contextRef = useRef<ArcContextAny | null>(preloadedContext ?? null);
184
+ if (!contextRef.current) {
185
+ const resolved = loader.context ?? getContext();
186
+ if (resolved) contextRef.current = resolved;
187
+ }
188
+ const context = contextRef.current;
183
189
 
184
190
  const resolvedWsUrl = wsUrl ?? `${location.protocol}//${location.host}`;
185
191
 
package/src/registry.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  ArcLayoutComponent,
4
4
  ArcModule,
5
5
  ContextElementFragment,
6
+ ModuleAccess,
6
7
  PageFragment,
7
8
  SlotFragment,
8
9
  SlotId,
@@ -14,6 +15,8 @@ import type {
14
15
  // ---------------------------------------------------------------------------
15
16
 
16
17
  const modules: Map<string, ArcModule> = new Map();
18
+ /** Set of module names currently active (visible). Managed by syncModules. */
19
+ let activeModuleNames: Set<string> | null = null; // null = all active (no filtering)
17
20
  const listeners: Set<() => void> = new Set();
18
21
 
19
22
  function notify(): void {
@@ -25,8 +28,32 @@ export function subscribe(listener: () => void): () => void {
25
28
  return () => listeners.delete(listener);
26
29
  }
27
30
 
28
- /** Register module. Overwrites if already registered (needed for reload). */
31
+ /**
32
+ * Register module. Skips if a module with the same name already exists
33
+ * (prevents duplicates from bundled dependencies importing the same module).
34
+ * Use `forceRegisterModule` for dev reloads where code has actually changed.
35
+ */
29
36
  export function registerModule(mod: ArcModule): void {
37
+ for (const existing of modules.values()) {
38
+ if (existing.name === mod.name) {
39
+ console.log(`[arc:registry] SKIP ${mod.name} (already registered as ${existing.id})`);
40
+ return;
41
+ }
42
+ }
43
+ console.log(`[arc:registry] REGISTER ${mod.name} as ${mod.id} (fragments: ${mod.fragments.length})`);
44
+ modules.set(mod.id, mod);
45
+ notify();
46
+ }
47
+
48
+ /** Force-register module, replacing any existing module with the same name.
49
+ * Used for dev reloads where code has changed on disk. */
50
+ export function forceRegisterModule(mod: ArcModule): void {
51
+ for (const [id, existing] of modules) {
52
+ if (existing.name === mod.name && id !== mod.id) {
53
+ modules.delete(id);
54
+ break;
55
+ }
56
+ }
30
57
  modules.set(mod.id, mod);
31
58
  notify();
32
59
  }
@@ -41,9 +68,22 @@ export function getModule(moduleId: string): ArcModule | undefined {
41
68
  }
42
69
 
43
70
  export function getAllModules(): ArcModule[] {
71
+ if (!activeModuleNames) return [...modules.values()];
72
+ return [...modules.values()].filter((m) => activeModuleNames!.has(m.name));
73
+ }
74
+
75
+ /** Get ALL registered modules (ignoring active filter). */
76
+ export function getAllRegisteredModules(): ArcModule[] {
44
77
  return [...modules.values()];
45
78
  }
46
79
 
80
+ /** Set which modules are currently active (visible in queries).
81
+ * Pass null to show all. Triggers re-render of slot/page subscribers. */
82
+ export function setActiveModules(names: Set<string> | null): void {
83
+ activeModuleNames = names;
84
+ notify();
85
+ }
86
+
47
87
  // ---------------------------------------------------------------------------
48
88
  // Context store — dynamiczny context rejestrowany przez pakiety
49
89
  // ---------------------------------------------------------------------------
@@ -166,7 +206,8 @@ export function getSlotFragments(slotId: SlotId | string): SlotFragment[] {
166
206
  }
167
207
 
168
208
  export function getPageFragments(): PageFragment[] {
169
- return getAllFragments().filter((f): f is PageFragment => f.is("page"));
209
+ const fragments = getAllFragments().filter((f): f is PageFragment => f.is("page"));
210
+ return sortByOrdering(fragments);
170
211
  }
171
212
 
172
213
  export function getContextElementFragments(): ContextElementFragment[] {
@@ -186,6 +227,23 @@ export function getWrapperFragments(): WrapperFragment[] {
186
227
  return sortByOrdering(fragments);
187
228
  }
188
229
 
230
+ /** Get access rules for a module by name. */
231
+ export function getModuleAccess(name: string): ModuleAccess | undefined {
232
+ for (const mod of modules.values()) {
233
+ if (mod.name === name && mod.access) return mod.access;
234
+ }
235
+ return undefined;
236
+ }
237
+
238
+ /** Get access rules for all protected modules. */
239
+ export function getAllModuleAccess(): Map<string, ModuleAccess> {
240
+ const result = new Map<string, ModuleAccess>();
241
+ for (const mod of modules.values()) {
242
+ if (mod.access) result.set(mod.name, mod.access);
243
+ }
244
+ return result;
245
+ }
246
+
189
247
  /** Clear all modules (but keep context). Used for reload. */
190
248
  export function clearModules(): void {
191
249
  modules.clear();
package/src/types.ts CHANGED
@@ -57,6 +57,9 @@ export interface PageFragment<
57
57
  readonly label?: string | ReactNode;
58
58
  readonly tooltip?: string | ReactNode;
59
59
  readonly children?: readonly PageFragment[];
60
+ readonly ordering: FragmentOrdering;
61
+ /** Show in main navigation. Defaults to auto-detect (has icon + label). */
62
+ readonly nav?: boolean;
60
63
  }
61
64
 
62
65
  export type PageShellProps = {
@@ -83,14 +86,35 @@ export type ArcFragment =
83
86
 
84
87
  export type PublicArcFragment = ArcFragment;
85
88
 
89
+ // ---------------------------------------------------------------------------
90
+ // Module access control
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export interface ModuleAccessRule {
94
+ readonly token: { name: string; [key: string]: any };
95
+ readonly check?: (tokenInstance: any) => boolean | Promise<boolean>;
96
+ }
97
+
98
+ export interface ModuleAccess {
99
+ readonly rules: readonly ModuleAccessRule[];
100
+ }
101
+
102
+ export interface ModuleDescriptor {
103
+ readonly file: string;
104
+ readonly name: string;
105
+ readonly url?: string;
106
+ }
107
+
86
108
  // ---------------------------------------------------------------------------
87
109
  // Moduł
88
110
  // ---------------------------------------------------------------------------
89
111
 
90
112
  export interface ArcModule {
91
113
  readonly id: string;
114
+ readonly name: string;
92
115
  readonly fragments: readonly ArcFragment[];
93
116
  readonly publicFragments: readonly ArcFragment[];
117
+ readonly access?: ModuleAccess;
94
118
  }
95
119
 
96
120
  // ---------------------------------------------------------------------------
@@ -137,6 +161,12 @@ export interface PageOptions {
137
161
  label?: string | ReactNode;
138
162
  tooltip?: string | ReactNode;
139
163
  children?: PageFragment[];
164
+ /** Navigation ordering. Lower = earlier. Default 0. */
165
+ order?: number;
166
+ after?: string;
167
+ before?: string;
168
+ /** Explicitly include/exclude from main navigation. Default: auto (has icon + label). */
169
+ nav?: boolean;
140
170
  }
141
171
 
142
172
  export interface WrapperOptions extends FragmentOrdering {