@cosmicdrift/kumiko-framework 0.318.0 → 0.320.0

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.
Files changed (46) hide show
  1. package/package.json +8 -4
  2. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
  4. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
  5. package/src/api/__tests__/client-ip.test.ts +139 -0
  6. package/src/api/__tests__/extra-routes.integration.test.ts +8 -1
  7. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
  8. package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
  9. package/src/api/__tests__/post-auth-landing.test.ts +100 -0
  10. package/src/api/__tests__/request-id-middleware.test.ts +35 -2
  11. package/src/api/__tests__/schema-route.integration.test.ts +157 -0
  12. package/src/api/api-constants.ts +2 -0
  13. package/src/api/auth-routes.ts +92 -95
  14. package/src/api/client-ip.ts +91 -0
  15. package/src/api/index.ts +15 -0
  16. package/src/api/pat-route-guard.ts +51 -0
  17. package/src/api/post-auth-landing.ts +105 -0
  18. package/src/api/request-context.ts +4 -2
  19. package/src/api/request-id-middleware.ts +44 -14
  20. package/src/api/server.ts +128 -40
  21. package/src/changes.json +73 -0
  22. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
  23. package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
  24. package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
  25. package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
  26. package/src/engine/boot-validator/pii-retention.ts +18 -2
  27. package/src/engine/boot-validator/screens.ts +75 -18
  28. package/src/engine/index.ts +1 -0
  29. package/src/engine/project-app-schema-for-roles.ts +950 -0
  30. package/src/engine/types/handlers.ts +1 -1
  31. package/src/engine/types/index.ts +5 -1
  32. package/src/entrypoint/index.ts +14 -1
  33. package/src/files/__tests__/files.integration.test.ts +105 -1
  34. package/src/files/file-routes.ts +54 -2
  35. package/src/files/index.ts +1 -1
  36. package/src/http/index.ts +10 -2
  37. package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
  38. package/src/jobs/job-runner.ts +6 -1
  39. package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
  40. package/src/rate-limit/middleware.ts +32 -8
  41. package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
  42. package/src/stack/push-entity-projection-tables.ts +52 -7
  43. package/src/stack/table-helpers.ts +17 -9
  44. package/src/stack/test-stack.ts +28 -1
  45. package/src/testing/real-providers.ts +2 -0
  46. package/src/ui-types/index.ts +1 -1
@@ -0,0 +1,950 @@
1
+ // Per-role projection of a fully-built AppSchema (fw#3314). GET /api/schema
2
+ // used to ship the same full schema — screens, navs, workspaces, content
3
+ // collections — to every authenticated user regardless of role, relying on
4
+ // client-side UI checks (isUiAccessGranted) alone to hide what a role
5
+ // shouldn't see. That leaves the raw schema JSON itself as a disclosure
6
+ // surface (role-gated screen ids/labels/handler QNs visible to anyone who
7
+ // can read the response). This module removes what a set of roles may not
8
+ // see from the schema BEFORE it's serialized — entities and translations
9
+ // stay complete (decided: option (a), a projected schema is still one
10
+ // screen/nav/workspace registry, not a per-entity capability list).
11
+ //
12
+ // Pure function: never mutates `schema`, returns a new object only where
13
+ // something actually changed — every helper below returns the ORIGINAL
14
+ // reference when nothing needed stripping, so a fully-open role (no access
15
+ // rules anywhere, or a role matching every rule) gets back the exact same
16
+ // object graph it was given, not a structurally-equal copy. That relies on
17
+ // object-spread's `...(cond && { key })` idiom already used throughout
18
+ // build-app-schema.ts: spreading `false` contributes nothing, so an
19
+ // omitted/undefined key never round-trips into an explicit `undefined`
20
+ // (buildAppSchema's own JSON-safety check would reject that).
21
+ //
22
+ // The dispatcher's own `hasAccess` (./access.ts) is UNCHANGED by this file —
23
+ // that's the default-deny handler-invocation gate, still the sole authority
24
+ // over whether a write/query actually executes. This module only decides
25
+ // what a client's schema fetch renders/lists, using the default-VISIBLE
26
+ // `isUiAccessGranted` predicate (see its doc comment in
27
+ // @cosmicdrift/kumiko-types/handlers) — an unset `access` here means every
28
+ // signed-in user may see the entry, the opposite stance from `hasAccess`.
29
+ //
30
+ // Reference-site coverage (every place boot-validator/screens.ts resolves a
31
+ // screen target is a site this file also projects):
32
+ // - nav.screen / nav.parent (author-registered AND settings-hub-generated
33
+ // navs — the latter use short ids relative to the synthetic "config"
34
+ // feature, so target resolution applies the same isValidQn-guarded
35
+ // qualify-or-verbatim rule uniformly, never assuming pre-qualification)
36
+ // - nav/content-collection actions[] and createAction (TreeAction.screen —
37
+ // always fully qualified per validateTreeAction, checked directly)
38
+ // - rowActions/toolbarActions on entityList/projectionList (kind:"navigate"
39
+ // resolves app-wide via a global short-id map, mirroring
40
+ // resolveRowActionNavigateTarget; kind:"drawer" resolves same-feature
41
+ // only, mirroring validateDrawerTargetAction)
42
+ // - projectionDetail header actions + metrics[].navigate
43
+ // - entityEdit/actionForm/secretMint redirect + actionForm cancelTarget
44
+ // (resolveScreenTargetQn: same-feature short id or cross-feature QN)
45
+ // - dashboard DashboardScreenPanel.screen (resolveScreenTargetQn)
46
+ // - every EditSectionSpec kind's own `actions` (fields/extension/writeForm/
47
+ // relatedList all carry one), plus relatedList's rowActions,
48
+ // toolbarActions, rowClick.entity (via detailFor) and
49
+ // emptyState.action — leak sites beyond the spec's literal enumeration,
50
+ // closed here because they're real screen-navigate fields the same
51
+ // boot-validator machinery resolves
52
+ // - QualifiedContentCollection.nav.actions/createAction — a verbatim copy
53
+ // distinct from the "real" nav entry's own actions, not kept in sync by
54
+ // buildAppSchema
55
+ // - WorkspaceSchema.definition.nav — the raw explicit nav-QN list,
56
+ // verbatim in JSON, distinct from the computed/filtered navMembers
57
+ // - listScreenId (projectionDetail/entityEdit/actionForm/secretMint/custom —
58
+ // the breadcrumb "back to list" target). It's serialized, so a visible
59
+ // screen would otherwise disclose a denied screen's short id. Resolved
60
+ // the same way resolveNavParentScreen/renderer breadcrumbs do: a global
61
+ // short-id lookup across every feature's screens (screen-helpers.ts),
62
+ // the same convention as RowAction/ToolbarAction navigate-by-`.screen`,
63
+ // not the same-feature-or-QN rule `resolveScreenTargetQn` uses.
64
+ //
65
+ // Deliberately OUT of scope: `TargetRef`-based `target` fields on nav/tree
66
+ // entries (an EditorPanel dispatch target, not a screen navigate — nothing
67
+ // to resolve against keptScreenQns).
68
+
69
+ import type {
70
+ AppSchema,
71
+ FeatureSchema,
72
+ QualifiedContentCollection,
73
+ WorkspaceSchema,
74
+ } from "../ui-types/app-schema";
75
+ import { resolveScreenTargetQn } from "./boot-validator/screens";
76
+ import { isValidQn, qualifyEntityName } from "./qualified-name";
77
+ import type { AccessRule } from "./types/handlers";
78
+ import { isUiAccessGranted } from "./types/handlers";
79
+ import type { NavDefinition } from "./types/nav";
80
+ import type {
81
+ ActionFormRedirect,
82
+ ActionFormScreenDefinition,
83
+ DashboardPanelDefinition,
84
+ EditLayout,
85
+ EditRelatedListSection,
86
+ EditSectionSpec,
87
+ EntityEditScreenDefinition,
88
+ EntityListScreenDefinition,
89
+ MetricNavigate,
90
+ MetricSpec,
91
+ ProjectionDetailScreenDefinition,
92
+ ProjectionListScreenDefinition,
93
+ RelatedListToolbarAction,
94
+ RowAction,
95
+ ScreenDefinition,
96
+ SecretMintScreenDefinition,
97
+ ToolbarAction,
98
+ } from "./types/screen";
99
+ import type { TreeAction } from "./types/tree-node";
100
+ import type { WorkspaceDefinition } from "./types/workspace";
101
+
102
+ type ScreenLocation = { readonly featureName: string; readonly screen: ScreenDefinition };
103
+
104
+ type IndexedSchema = {
105
+ readonly screensByShortId: ReadonlyMap<string, ScreenLocation>;
106
+ readonly detailForScreens: ReadonlyMap<string, ScreenLocation>;
107
+ };
108
+
109
+ type NavContext = {
110
+ readonly qn: string;
111
+ readonly featureName: string;
112
+ readonly nav: NavDefinition;
113
+ readonly resolvedScreenQn: string | undefined;
114
+ readonly resolvedParentQn: string | undefined;
115
+ };
116
+
117
+ export function projectAppSchemaForRoles(schema: AppSchema, roles: readonly string[]): AppSchema {
118
+ const keptScreenQns = buildKeptScreenQns(schema, roles);
119
+ const indices = buildScreenIndices(schema);
120
+ const screenAccessByQn = buildScreenAccessMap(schema);
121
+ const navContexts = buildNavContexts(schema);
122
+ const droppedNavQns = buildDroppedNavQns(navContexts, screenAccessByQn, keptScreenQns, roles);
123
+ const survivingNavQns = new Set(
124
+ navContexts.filter((ctx) => !droppedNavQns.has(ctx.qn)).map((ctx) => ctx.qn),
125
+ );
126
+
127
+ let featuresChanged = false;
128
+ const nextFeatures = schema.features.map((feature) => {
129
+ const nextFeature = projectFeature(
130
+ feature,
131
+ roles,
132
+ keptScreenQns,
133
+ indices,
134
+ droppedNavQns,
135
+ survivingNavQns,
136
+ );
137
+ if (nextFeature !== feature) featuresChanged = true;
138
+ return nextFeature;
139
+ });
140
+
141
+ const nextWorkspaces = projectWorkspaces(schema.workspaces, roles, survivingNavQns);
142
+
143
+ if (!featuresChanged && nextWorkspaces === schema.workspaces) return schema;
144
+
145
+ const { workspaces, ...restSchema } = schema;
146
+ return {
147
+ ...restSchema,
148
+ features: featuresChanged ? nextFeatures : schema.features,
149
+ ...(nextWorkspaces !== undefined && { workspaces: nextWorkspaces }),
150
+ };
151
+ }
152
+
153
+ // --- indices over the ORIGINAL (unprojected) schema ---
154
+
155
+ function buildKeptScreenQns(schema: AppSchema, roles: readonly string[]): ReadonlySet<string> {
156
+ const kept = new Set<string>();
157
+ for (const feature of schema.features) {
158
+ for (const screen of feature.screens) {
159
+ if (isUiAccessGranted(screen.access, roles)) {
160
+ kept.add(qualifyEntityName(feature.featureName, "screen", screen.id));
161
+ }
162
+ }
163
+ }
164
+ return kept;
165
+ }
166
+
167
+ function buildScreenIndices(schema: AppSchema): IndexedSchema {
168
+ const screensByShortId = new Map<string, ScreenLocation>();
169
+ const detailForScreens = new Map<string, ScreenLocation>();
170
+ for (const feature of schema.features) {
171
+ for (const screen of feature.screens) {
172
+ if (!screensByShortId.has(screen.id)) {
173
+ screensByShortId.set(screen.id, { featureName: feature.featureName, screen });
174
+ }
175
+ if (screen.detailFor !== undefined && !detailForScreens.has(screen.detailFor)) {
176
+ detailForScreens.set(screen.detailFor, { featureName: feature.featureName, screen });
177
+ }
178
+ }
179
+ }
180
+ return { screensByShortId, detailForScreens };
181
+ }
182
+
183
+ function buildScreenAccessMap(schema: AppSchema): ReadonlyMap<string, AccessRule | undefined> {
184
+ const map = new Map<string, AccessRule | undefined>();
185
+ for (const feature of schema.features) {
186
+ for (const screen of feature.screens) {
187
+ map.set(qualifyEntityName(feature.featureName, "screen", screen.id), screen.access);
188
+ }
189
+ }
190
+ return map;
191
+ }
192
+
193
+ // Settings-hub-generated navs share the short-id-relative-to-owning-feature
194
+ // convention with author-registered ones (they're merged verbatim into the
195
+ // synthetic "config" feature's own `navs`) — the isValidQn guard makes this
196
+ // resolution correct for both without needing to know which regime a given
197
+ // nav came from.
198
+ function resolveNavTargetQn(featureName: string, ref: string): string {
199
+ return isValidQn(ref) ? ref : qualifyEntityName(featureName, "nav", ref);
200
+ }
201
+
202
+ function buildNavContexts(schema: AppSchema): readonly NavContext[] {
203
+ const contexts: NavContext[] = [];
204
+ for (const feature of schema.features) {
205
+ for (const nav of feature.navs ?? []) {
206
+ contexts.push({
207
+ qn: qualifyEntityName(feature.featureName, "nav", nav.id),
208
+ featureName: feature.featureName,
209
+ nav,
210
+ resolvedScreenQn:
211
+ nav.screen !== undefined
212
+ ? resolveScreenTargetQn(feature.featureName, nav.screen)
213
+ : undefined,
214
+ resolvedParentQn:
215
+ nav.parent !== undefined
216
+ ? resolveNavTargetQn(feature.featureName, nav.parent)
217
+ : undefined,
218
+ });
219
+ }
220
+ }
221
+ return contexts;
222
+ }
223
+
224
+ // Initial per-nav drop decision (access + dead screen target) followed by a
225
+ // fixed-point propagation: a dropped parent drops its children, and a
226
+ // screenless grouping node that loses every child (and isn't a runtime
227
+ // `provider: true` node, whose children never come from other navs'
228
+ // `.parent`) drops itself too — an empty section is not worth keeping.
229
+ function buildDroppedNavQns(
230
+ navContexts: readonly NavContext[],
231
+ screenAccessByQn: ReadonlyMap<string, AccessRule | undefined>,
232
+ keptScreenQns: ReadonlySet<string>,
233
+ roles: readonly string[],
234
+ ): ReadonlySet<string> {
235
+ const childrenByParent = groupNavChildrenByParent(navContexts);
236
+ const dropped = new Set<string>(
237
+ navContexts
238
+ .filter((ctx) => isNavDeniedOrDead(ctx, screenAccessByQn, keptScreenQns, roles))
239
+ .map((ctx) => ctx.qn),
240
+ );
241
+
242
+ let changed = true;
243
+ while (changed) {
244
+ changed = false;
245
+ for (const ctx of navContexts) {
246
+ if (dropped.has(ctx.qn)) continue;
247
+ if (isDroppedByPropagation(ctx, dropped, childrenByParent)) {
248
+ dropped.add(ctx.qn);
249
+ changed = true;
250
+ }
251
+ }
252
+ }
253
+ return dropped;
254
+ }
255
+
256
+ function groupNavChildrenByParent(
257
+ navContexts: readonly NavContext[],
258
+ ): ReadonlyMap<string, readonly NavContext[]> {
259
+ const childrenByParent = new Map<string, NavContext[]>();
260
+ for (const ctx of navContexts) {
261
+ if (ctx.resolvedParentQn === undefined) continue;
262
+ const siblings = childrenByParent.get(ctx.resolvedParentQn) ?? [];
263
+ siblings.push(ctx);
264
+ childrenByParent.set(ctx.resolvedParentQn, siblings);
265
+ }
266
+ return childrenByParent;
267
+ }
268
+
269
+ function isNavDeniedOrDead(
270
+ ctx: NavContext,
271
+ screenAccessByQn: ReadonlyMap<string, AccessRule | undefined>,
272
+ keptScreenQns: ReadonlySet<string>,
273
+ roles: readonly string[],
274
+ ): boolean {
275
+ // `nav.access` already carries `nav.access ?? collection.access` for a
276
+ // content-collection's synthesized nav entry (feature-ui-extensions.ts
277
+ // sets it at registration time) — no separate collection-access lookup
278
+ // needed here.
279
+ if (ctx.resolvedScreenQn === undefined) return !isUiAccessGranted(ctx.nav.access, roles);
280
+ if (!keptScreenQns.has(ctx.resolvedScreenQn)) return true;
281
+ const effectiveAccess = ctx.nav.access ?? screenAccessByQn.get(ctx.resolvedScreenQn);
282
+ return !isUiAccessGranted(effectiveAccess, roles);
283
+ }
284
+
285
+ function isDroppedByPropagation(
286
+ ctx: NavContext,
287
+ dropped: ReadonlySet<string>,
288
+ childrenByParent: ReadonlyMap<string, readonly NavContext[]>,
289
+ ): boolean {
290
+ if (ctx.resolvedParentQn !== undefined && dropped.has(ctx.resolvedParentQn)) return true;
291
+ const isPureGroupingNode =
292
+ ctx.nav.screen === undefined && ctx.nav.target === undefined && ctx.nav.provider !== true;
293
+ if (!isPureGroupingNode) return false;
294
+ const children = childrenByParent.get(ctx.qn) ?? [];
295
+ return children.length > 0 && children.every((child) => dropped.has(child.qn));
296
+ }
297
+
298
+ // --- screen-target resolution helpers (mirror boot-validator/screens.ts) ---
299
+
300
+ function isScreenTargetKept(
301
+ featureName: string,
302
+ target: string,
303
+ keptScreenQns: ReadonlySet<string>,
304
+ ): boolean {
305
+ return keptScreenQns.has(resolveScreenTargetQn(featureName, target));
306
+ }
307
+
308
+ function isRedirectTargetKept(
309
+ featureName: string,
310
+ target: string | ActionFormRedirect,
311
+ keptScreenQns: ReadonlySet<string>,
312
+ ): boolean {
313
+ const screenRef = typeof target === "string" ? target : target.screen;
314
+ return isScreenTargetKept(featureName, screenRef, keptScreenQns);
315
+ }
316
+
317
+ function isDrawerTargetKept(
318
+ feature: FeatureSchema,
319
+ shortId: string,
320
+ keptScreenQns: ReadonlySet<string>,
321
+ ): boolean {
322
+ return keptScreenQns.has(qualifyEntityName(feature.featureName, "screen", shortId));
323
+ }
324
+
325
+ function isListScreenIdKept(
326
+ listScreenId: string,
327
+ indices: IndexedSchema,
328
+ keptScreenQns: ReadonlySet<string>,
329
+ ): boolean {
330
+ const target = indices.screensByShortId.get(listScreenId);
331
+ if (target === undefined) return false;
332
+ return keptScreenQns.has(qualifyEntityName(target.featureName, "screen", target.screen.id));
333
+ }
334
+
335
+ function resolveNavigateTarget(
336
+ action: { readonly screen?: string; readonly entity?: string },
337
+ indices: IndexedSchema,
338
+ ): ScreenLocation | undefined {
339
+ if (action.entity !== undefined) return indices.detailForScreens.get(action.entity);
340
+ if (action.screen !== undefined) return indices.screensByShortId.get(action.screen);
341
+ return undefined;
342
+ }
343
+
344
+ function isNavigateTargetKept(
345
+ action: { readonly screen?: string; readonly entity?: string },
346
+ indices: IndexedSchema,
347
+ keptScreenQns: ReadonlySet<string>,
348
+ ): boolean {
349
+ const target = resolveNavigateTarget(action, indices);
350
+ if (target === undefined) return false;
351
+ return keptScreenQns.has(qualifyEntityName(target.featureName, "screen", target.screen.id));
352
+ }
353
+
354
+ function keepMetricNavigate(
355
+ navigate: MetricNavigate,
356
+ indices: IndexedSchema,
357
+ keptScreenQns: ReadonlySet<string>,
358
+ ): boolean {
359
+ if (navigate.screen === undefined && navigate.entity === undefined) return true; // same-record tab jump
360
+ return isNavigateTargetKept(navigate, indices, keptScreenQns);
361
+ }
362
+
363
+ function keepTreeAction(action: TreeAction, keptScreenQns: ReadonlySet<string>): boolean {
364
+ if (action.screen === undefined) return true; // target-based dispatch, not a screen navigate
365
+ return keptScreenQns.has(action.screen);
366
+ }
367
+
368
+ // --- RowAction / ToolbarAction / RelatedListToolbarAction array projectors ---
369
+ // Each returns the ORIGINAL array reference when nothing was filtered, and
370
+ // `undefined` (never an empty array) when everything was — same JSON-safety
371
+ // convention as buildAppSchema.
372
+
373
+ function keepRowAction(
374
+ action: RowAction,
375
+ feature: FeatureSchema,
376
+ indices: IndexedSchema,
377
+ keptScreenQns: ReadonlySet<string>,
378
+ ): boolean {
379
+ if (action.kind === "drawer") return isDrawerTargetKept(feature, action.screen, keptScreenQns);
380
+ if (action.kind === "navigate") return isNavigateTargetKept(action, indices, keptScreenQns);
381
+ return true; // writeHandler — no screen target
382
+ }
383
+
384
+ function projectRowActionArray(
385
+ actions: readonly RowAction[] | undefined,
386
+ feature: FeatureSchema,
387
+ indices: IndexedSchema,
388
+ keptScreenQns: ReadonlySet<string>,
389
+ ): readonly RowAction[] | undefined {
390
+ if (actions === undefined) return undefined;
391
+ const kept = actions.filter((a) => keepRowAction(a, feature, indices, keptScreenQns));
392
+ if (kept.length === actions.length) return actions;
393
+ return kept.length > 0 ? kept : undefined;
394
+ }
395
+
396
+ function keepToolbarAction(
397
+ action: ToolbarAction,
398
+ feature: FeatureSchema,
399
+ indices: IndexedSchema,
400
+ keptScreenQns: ReadonlySet<string>,
401
+ ): boolean {
402
+ if (action.kind === "navigate") return isNavigateTargetKept(action, indices, keptScreenQns);
403
+ if (action.kind === "drawer") return isDrawerTargetKept(feature, action.screen, keptScreenQns);
404
+ return true;
405
+ }
406
+
407
+ function projectToolbarActionArray(
408
+ actions: readonly ToolbarAction[] | undefined,
409
+ feature: FeatureSchema,
410
+ indices: IndexedSchema,
411
+ keptScreenQns: ReadonlySet<string>,
412
+ ): readonly ToolbarAction[] | undefined {
413
+ if (actions === undefined) return undefined;
414
+ const kept = actions.filter((a) => keepToolbarAction(a, feature, indices, keptScreenQns));
415
+ if (kept.length === actions.length) return actions;
416
+ return kept.length > 0 ? kept : undefined;
417
+ }
418
+
419
+ function keepRelatedListToolbarAction(
420
+ action: RelatedListToolbarAction,
421
+ feature: FeatureSchema,
422
+ indices: IndexedSchema,
423
+ keptScreenQns: ReadonlySet<string>,
424
+ ): boolean {
425
+ if (action.kind === "navigate") return isNavigateTargetKept(action, indices, keptScreenQns);
426
+ if (action.kind === "drawer") return isDrawerTargetKept(feature, action.screen, keptScreenQns);
427
+ return true;
428
+ }
429
+
430
+ function projectRelatedListToolbarActionArray(
431
+ actions: readonly RelatedListToolbarAction[] | undefined,
432
+ feature: FeatureSchema,
433
+ indices: IndexedSchema,
434
+ keptScreenQns: ReadonlySet<string>,
435
+ ): readonly RelatedListToolbarAction[] | undefined {
436
+ if (actions === undefined) return undefined;
437
+ const kept = actions.filter((a) =>
438
+ keepRelatedListToolbarAction(a, feature, indices, keptScreenQns),
439
+ );
440
+ if (kept.length === actions.length) return actions;
441
+ return kept.length > 0 ? kept : undefined;
442
+ }
443
+
444
+ function projectTreeActionArray(
445
+ actions: readonly TreeAction[] | undefined,
446
+ keptScreenQns: ReadonlySet<string>,
447
+ ): readonly TreeAction[] | undefined {
448
+ if (actions === undefined) return undefined;
449
+ const kept = actions.filter((a) => keepTreeAction(a, keptScreenQns));
450
+ if (kept.length === actions.length) return actions;
451
+ return kept.length > 0 ? kept : undefined;
452
+ }
453
+
454
+ function projectTreeAction(
455
+ action: TreeAction | undefined,
456
+ keptScreenQns: ReadonlySet<string>,
457
+ ): TreeAction | undefined {
458
+ if (action === undefined) return undefined;
459
+ return keepTreeAction(action, keptScreenQns) ? action : undefined;
460
+ }
461
+
462
+ function isRowClickEntityKept(
463
+ entity: string,
464
+ indices: IndexedSchema,
465
+ keptScreenQns: ReadonlySet<string>,
466
+ ): boolean {
467
+ const target = indices.detailForScreens.get(entity);
468
+ if (target === undefined) return false;
469
+ return keptScreenQns.has(qualifyEntityName(target.featureName, "screen", target.screen.id));
470
+ }
471
+
472
+ // --- EditLayout / EditSectionSpec projection ---
473
+
474
+ function projectEmptyState(
475
+ emptyState: EditRelatedListSection["emptyState"],
476
+ feature: FeatureSchema,
477
+ indices: IndexedSchema,
478
+ keptScreenQns: ReadonlySet<string>,
479
+ ): EditRelatedListSection["emptyState"] {
480
+ if (emptyState === undefined) return undefined;
481
+ if (emptyState.action === undefined) return emptyState;
482
+ if (keepRowAction(emptyState.action, feature, indices, keptScreenQns)) return emptyState;
483
+ const { action, ...rest } = emptyState;
484
+ return rest;
485
+ }
486
+
487
+ function projectRelatedListSection(
488
+ section: EditRelatedListSection,
489
+ feature: FeatureSchema,
490
+ indices: IndexedSchema,
491
+ keptScreenQns: ReadonlySet<string>,
492
+ ): EditRelatedListSection {
493
+ const { actions, rowActions, toolbarActions, rowClick, emptyState, ...rest } = section;
494
+ const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
495
+ const nextRowActions = projectRowActionArray(rowActions, feature, indices, keptScreenQns);
496
+ const nextToolbarActions = projectRelatedListToolbarActionArray(
497
+ toolbarActions,
498
+ feature,
499
+ indices,
500
+ keptScreenQns,
501
+ );
502
+ const nextRowClick =
503
+ rowClick === undefined || isRowClickEntityKept(rowClick.entity, indices, keptScreenQns)
504
+ ? rowClick
505
+ : undefined;
506
+ const nextEmptyState = projectEmptyState(emptyState, feature, indices, keptScreenQns);
507
+ if (
508
+ nextActions === actions &&
509
+ nextRowActions === rowActions &&
510
+ nextToolbarActions === toolbarActions &&
511
+ nextRowClick === rowClick &&
512
+ nextEmptyState === emptyState
513
+ ) {
514
+ return section;
515
+ }
516
+ return {
517
+ ...rest,
518
+ ...(nextActions !== undefined && { actions: nextActions }),
519
+ ...(nextRowActions !== undefined && { rowActions: nextRowActions }),
520
+ ...(nextToolbarActions !== undefined && { toolbarActions: nextToolbarActions }),
521
+ ...(nextRowClick !== undefined && { rowClick: nextRowClick }),
522
+ ...(nextEmptyState !== undefined && { emptyState: nextEmptyState }),
523
+ };
524
+ }
525
+
526
+ function projectEditSection(
527
+ section: EditSectionSpec,
528
+ feature: FeatureSchema,
529
+ indices: IndexedSchema,
530
+ keptScreenQns: ReadonlySet<string>,
531
+ ): EditSectionSpec {
532
+ switch (section.kind) {
533
+ case "relatedList":
534
+ return projectRelatedListSection(section, feature, indices, keptScreenQns);
535
+ case "extension": {
536
+ const { actions, ...rest } = section;
537
+ const kept = projectRowActionArray(actions, feature, indices, keptScreenQns);
538
+ return kept === actions ? section : { ...rest, ...(kept !== undefined && { actions: kept }) };
539
+ }
540
+ case "writeForm": {
541
+ const { actions, ...rest } = section;
542
+ const kept = projectRowActionArray(actions, feature, indices, keptScreenQns);
543
+ return kept === actions ? section : { ...rest, ...(kept !== undefined && { actions: kept }) };
544
+ }
545
+ default: {
546
+ // kind undefined or "fields"
547
+ const { actions, ...rest } = section;
548
+ const kept = projectRowActionArray(actions, feature, indices, keptScreenQns);
549
+ return kept === actions ? section : { ...rest, ...(kept !== undefined && { actions: kept }) };
550
+ }
551
+ }
552
+ }
553
+
554
+ function projectEditLayout(
555
+ layout: EditLayout,
556
+ feature: FeatureSchema,
557
+ indices: IndexedSchema,
558
+ keptScreenQns: ReadonlySet<string>,
559
+ ): EditLayout {
560
+ const nextSections = layout.sections.map((s) =>
561
+ projectEditSection(s, feature, indices, keptScreenQns),
562
+ );
563
+ const changed = nextSections.some((s, i) => s !== layout.sections[i]);
564
+ if (!changed) return layout;
565
+ return { ...layout, sections: nextSections };
566
+ }
567
+
568
+ function projectMetrics(
569
+ metrics: readonly MetricSpec[] | undefined,
570
+ indices: IndexedSchema,
571
+ keptScreenQns: ReadonlySet<string>,
572
+ ): readonly MetricSpec[] | undefined {
573
+ if (metrics === undefined) return undefined;
574
+ let changed = false;
575
+ const next = metrics.map((m) => {
576
+ if (typeof m === "string" || m.navigate === undefined) return m;
577
+ if (keepMetricNavigate(m.navigate, indices, keptScreenQns)) return m;
578
+ changed = true;
579
+ const { navigate, ...rest } = m;
580
+ return rest;
581
+ });
582
+ return changed ? next : metrics;
583
+ }
584
+
585
+ function projectDashboardPanels(
586
+ panels: readonly DashboardPanelDefinition[],
587
+ feature: FeatureSchema,
588
+ keptScreenQns: ReadonlySet<string>,
589
+ ): readonly DashboardPanelDefinition[] {
590
+ const kept = panels.filter(
591
+ (p) => p.kind !== "screen" || isScreenTargetKept(feature.featureName, p.screen, keptScreenQns),
592
+ );
593
+ return kept.length === panels.length ? panels : kept;
594
+ }
595
+
596
+ // --- per-screen-type projection ---
597
+
598
+ function projectListScreenId(
599
+ listScreenId: string | undefined,
600
+ indices: IndexedSchema,
601
+ keptScreenQns: ReadonlySet<string>,
602
+ ): string | undefined {
603
+ if (listScreenId === undefined) return undefined;
604
+ return isListScreenIdKept(listScreenId, indices, keptScreenQns) ? listScreenId : undefined;
605
+ }
606
+
607
+ function projectRedirect<Redirect extends string | ActionFormRedirect>(
608
+ featureName: string,
609
+ redirect: Redirect | undefined,
610
+ keptScreenQns: ReadonlySet<string>,
611
+ ): Redirect | undefined {
612
+ if (redirect === undefined) return undefined;
613
+ return isRedirectTargetKept(featureName, redirect, keptScreenQns) ? redirect : undefined;
614
+ }
615
+
616
+ function projectCancelTarget(
617
+ featureName: string,
618
+ cancelTarget: string | false | undefined,
619
+ keptScreenQns: ReadonlySet<string>,
620
+ ): string | false | undefined {
621
+ if (cancelTarget === undefined || cancelTarget === false) return cancelTarget;
622
+ return isScreenTargetKept(featureName, cancelTarget, keptScreenQns) ? cancelTarget : undefined;
623
+ }
624
+
625
+ function projectListScreen(
626
+ screen: EntityListScreenDefinition | ProjectionListScreenDefinition,
627
+ feature: FeatureSchema,
628
+ indices: IndexedSchema,
629
+ keptScreenQns: ReadonlySet<string>,
630
+ ): ScreenDefinition {
631
+ const { rowActions, toolbarActions, ...rest } = screen;
632
+ const nextRowActions = projectRowActionArray(rowActions, feature, indices, keptScreenQns);
633
+ const nextToolbarActions = projectToolbarActionArray(
634
+ toolbarActions,
635
+ feature,
636
+ indices,
637
+ keptScreenQns,
638
+ );
639
+ if (nextRowActions === rowActions && nextToolbarActions === toolbarActions) return screen;
640
+ return {
641
+ ...rest,
642
+ ...(nextRowActions !== undefined && { rowActions: nextRowActions }),
643
+ ...(nextToolbarActions !== undefined && { toolbarActions: nextToolbarActions }),
644
+ };
645
+ }
646
+
647
+ function projectProjectionDetailScreen(
648
+ screen: ProjectionDetailScreenDefinition,
649
+ feature: FeatureSchema,
650
+ indices: IndexedSchema,
651
+ keptScreenQns: ReadonlySet<string>,
652
+ ): ScreenDefinition {
653
+ const { metrics, actions, layout, listScreenId, ...rest } = screen;
654
+ const nextMetrics = projectMetrics(metrics, indices, keptScreenQns);
655
+ const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
656
+ const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
657
+ const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
658
+ const unchanged =
659
+ nextMetrics === metrics &&
660
+ nextActions === actions &&
661
+ nextLayout === layout &&
662
+ nextListScreenId === listScreenId;
663
+ if (unchanged) return screen;
664
+ return {
665
+ ...rest,
666
+ layout: nextLayout,
667
+ ...(nextMetrics !== undefined && { metrics: nextMetrics }),
668
+ ...(nextActions !== undefined && { actions: nextActions }),
669
+ ...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
670
+ };
671
+ }
672
+
673
+ function projectEntityEditScreen(
674
+ screen: EntityEditScreenDefinition,
675
+ feature: FeatureSchema,
676
+ indices: IndexedSchema,
677
+ keptScreenQns: ReadonlySet<string>,
678
+ ): ScreenDefinition {
679
+ const { redirect, actions, layout, listScreenId, ...rest } = screen;
680
+ const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
681
+ const nextActions = projectRowActionArray(actions, feature, indices, keptScreenQns);
682
+ const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
683
+ const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
684
+ const unchanged =
685
+ nextRedirect === redirect &&
686
+ nextActions === actions &&
687
+ nextLayout === layout &&
688
+ nextListScreenId === listScreenId;
689
+ if (unchanged) return screen;
690
+ return {
691
+ ...rest,
692
+ layout: nextLayout,
693
+ ...(nextRedirect !== undefined && { redirect: nextRedirect }),
694
+ ...(nextActions !== undefined && { actions: nextActions }),
695
+ ...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
696
+ };
697
+ }
698
+
699
+ function projectActionFormScreen(
700
+ screen: ActionFormScreenDefinition,
701
+ feature: FeatureSchema,
702
+ indices: IndexedSchema,
703
+ keptScreenQns: ReadonlySet<string>,
704
+ ): ScreenDefinition {
705
+ const { redirect, cancelTarget, layout, listScreenId, ...rest } = screen;
706
+ const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
707
+ const nextCancelTarget = projectCancelTarget(feature.featureName, cancelTarget, keptScreenQns);
708
+ const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
709
+ const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
710
+ const unchanged =
711
+ nextRedirect === redirect &&
712
+ nextCancelTarget === cancelTarget &&
713
+ nextLayout === layout &&
714
+ nextListScreenId === listScreenId;
715
+ if (unchanged) return screen;
716
+ return {
717
+ ...rest,
718
+ layout: nextLayout,
719
+ ...(nextRedirect !== undefined && { redirect: nextRedirect }),
720
+ ...(nextCancelTarget !== undefined && { cancelTarget: nextCancelTarget }),
721
+ ...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
722
+ };
723
+ }
724
+
725
+ function projectSecretMintScreen(
726
+ screen: SecretMintScreenDefinition,
727
+ feature: FeatureSchema,
728
+ indices: IndexedSchema,
729
+ keptScreenQns: ReadonlySet<string>,
730
+ ): ScreenDefinition {
731
+ const { redirect, cancelTarget, layout, listScreenId, ...rest } = screen;
732
+ const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
733
+ const nextCancelTarget = projectCancelTarget(feature.featureName, cancelTarget, keptScreenQns);
734
+ const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
735
+ const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
736
+ const unchanged =
737
+ nextRedirect === redirect &&
738
+ nextCancelTarget === cancelTarget &&
739
+ nextLayout === layout &&
740
+ nextListScreenId === listScreenId;
741
+ if (unchanged) return screen;
742
+ return {
743
+ ...rest,
744
+ layout: nextLayout,
745
+ ...(nextRedirect !== undefined && { redirect: nextRedirect }),
746
+ ...(nextCancelTarget !== undefined && { cancelTarget: nextCancelTarget }),
747
+ ...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
748
+ };
749
+ }
750
+
751
+ function projectScreen(
752
+ screen: ScreenDefinition,
753
+ feature: FeatureSchema,
754
+ indices: IndexedSchema,
755
+ keptScreenQns: ReadonlySet<string>,
756
+ ): ScreenDefinition {
757
+ switch (screen.type) {
758
+ case "entityList":
759
+ case "projectionList":
760
+ return projectListScreen(screen, feature, indices, keptScreenQns);
761
+ case "projectionDetail":
762
+ return projectProjectionDetailScreen(screen, feature, indices, keptScreenQns);
763
+ case "dashboard": {
764
+ const nextPanels = projectDashboardPanels(screen.panels, feature, keptScreenQns);
765
+ return nextPanels === screen.panels ? screen : { ...screen, panels: nextPanels };
766
+ }
767
+ case "entityEdit":
768
+ return projectEntityEditScreen(screen, feature, indices, keptScreenQns);
769
+ case "actionForm":
770
+ return projectActionFormScreen(screen, feature, indices, keptScreenQns);
771
+ case "secretMint":
772
+ return projectSecretMintScreen(screen, feature, indices, keptScreenQns);
773
+ case "custom": {
774
+ const { listScreenId, ...rest } = screen;
775
+ const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
776
+ if (nextListScreenId === listScreenId) return screen;
777
+ return { ...rest, ...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }) };
778
+ }
779
+ // configEdit / secretsEdit carry no screen-navigate fields (see
780
+ // screen.ts's own definitions) — nothing to project.
781
+ default:
782
+ return screen;
783
+ }
784
+ }
785
+
786
+ // --- nav / content-collection / workspace projection ---
787
+
788
+ function projectNav(nav: NavDefinition, keptScreenQns: ReadonlySet<string>): NavDefinition {
789
+ const { actions, createAction, ...rest } = nav;
790
+ const nextActions = projectTreeActionArray(actions, keptScreenQns);
791
+ const nextCreateAction = projectTreeAction(createAction, keptScreenQns);
792
+ if (nextActions === actions && nextCreateAction === createAction) return nav;
793
+ return {
794
+ ...rest,
795
+ ...(nextActions !== undefined && { actions: nextActions }),
796
+ ...(nextCreateAction !== undefined && { createAction: nextCreateAction }),
797
+ };
798
+ }
799
+
800
+ function projectContentCollectionNav(
801
+ collection: QualifiedContentCollection,
802
+ keptScreenQns: ReadonlySet<string>,
803
+ ): QualifiedContentCollection {
804
+ const { actions, createAction, ...restNav } = collection.nav;
805
+ const nextActions = projectTreeActionArray(actions, keptScreenQns);
806
+ const nextCreateAction = projectTreeAction(createAction, keptScreenQns);
807
+ if (nextActions === actions && nextCreateAction === createAction) return collection;
808
+ return {
809
+ ...collection,
810
+ nav: {
811
+ ...restNav,
812
+ ...(nextActions !== undefined && { actions: nextActions }),
813
+ ...(nextCreateAction !== undefined && { createAction: nextCreateAction }),
814
+ },
815
+ };
816
+ }
817
+
818
+ function projectContentCollections(
819
+ collections: readonly QualifiedContentCollection[] | undefined,
820
+ roles: readonly string[],
821
+ survivingNavQns: ReadonlySet<string>,
822
+ keptScreenQns: ReadonlySet<string>,
823
+ ): readonly QualifiedContentCollection[] | undefined {
824
+ if (collections === undefined) return undefined;
825
+ let changed = false;
826
+ const kept: QualifiedContentCollection[] = [];
827
+ for (const collection of collections) {
828
+ if (!isUiAccessGranted(collection.access, roles) || !survivingNavQns.has(collection.navQn)) {
829
+ changed = true;
830
+ continue;
831
+ }
832
+ const nextCollection = projectContentCollectionNav(collection, keptScreenQns);
833
+ if (nextCollection !== collection) changed = true;
834
+ kept.push(nextCollection);
835
+ }
836
+ if (!changed) return collections;
837
+ return kept.length > 0 ? kept : undefined;
838
+ }
839
+
840
+ function projectWorkspaceMembership(
841
+ ws: WorkspaceSchema,
842
+ survivingNavQns: ReadonlySet<string>,
843
+ ): WorkspaceSchema | undefined {
844
+ const nextNavMembers = ws.navMembers.filter((qn) => survivingNavQns.has(qn));
845
+ // Only a workspace the projection emptied is dropped; one declared without
846
+ // members stays exactly as buildAppSchema produced it.
847
+ if (nextNavMembers.length === 0 && ws.navMembers.length > 0) return undefined;
848
+ const definitionNav: WorkspaceDefinition["nav"] = ws.definition.nav;
849
+ const nextDefinitionNav = definitionNav?.filter((qn) => survivingNavQns.has(qn));
850
+ const navMembersUnchanged = nextNavMembers.length === ws.navMembers.length;
851
+ const definitionNavUnchanged =
852
+ definitionNav === undefined || nextDefinitionNav?.length === definitionNav.length;
853
+ if (navMembersUnchanged && definitionNavUnchanged) return ws;
854
+ const { nav, ...restDefinition } = ws.definition;
855
+ return {
856
+ ...ws,
857
+ navMembers: nextNavMembers,
858
+ definition: {
859
+ ...restDefinition,
860
+ ...(nextDefinitionNav !== undefined &&
861
+ nextDefinitionNav.length > 0 && { nav: nextDefinitionNav }),
862
+ },
863
+ };
864
+ }
865
+
866
+ function projectWorkspaces(
867
+ workspaces: readonly WorkspaceSchema[] | undefined,
868
+ roles: readonly string[],
869
+ survivingNavQns: ReadonlySet<string>,
870
+ ): readonly WorkspaceSchema[] | undefined {
871
+ if (workspaces === undefined) return undefined;
872
+ let changed = false;
873
+ const kept: WorkspaceSchema[] = [];
874
+ for (const ws of workspaces) {
875
+ if (!isUiAccessGranted(ws.definition.access, roles)) {
876
+ changed = true;
877
+ continue;
878
+ }
879
+ const nextWorkspace = projectWorkspaceMembership(ws, survivingNavQns);
880
+ if (nextWorkspace === undefined) {
881
+ changed = true;
882
+ continue;
883
+ }
884
+ if (nextWorkspace !== ws) changed = true;
885
+ kept.push(nextWorkspace);
886
+ }
887
+ if (!changed) return workspaces;
888
+ // An empty result behaves identically to `undefined` on the client
889
+ // (WorkspaceShell's `hasWorkspaceMode` check requires `.length > 0`
890
+ // either way) — omit the key rather than ship `[]`, matching
891
+ // buildAppSchema's own JSON-safety convention.
892
+ return kept.length > 0 ? kept : undefined;
893
+ }
894
+
895
+ function projectFeature(
896
+ feature: FeatureSchema,
897
+ roles: readonly string[],
898
+ keptScreenQns: ReadonlySet<string>,
899
+ indices: IndexedSchema,
900
+ droppedNavQns: ReadonlySet<string>,
901
+ survivingNavQns: ReadonlySet<string>,
902
+ ): FeatureSchema {
903
+ const { screens, navs, contentCollections, workspaces, ...rest } = feature;
904
+
905
+ let screensChanged = false;
906
+ const nextScreens = screens
907
+ .filter((s) => keptScreenQns.has(qualifyEntityName(feature.featureName, "screen", s.id)))
908
+ .map((s) => {
909
+ const projected = projectScreen(s, feature, indices, keptScreenQns);
910
+ if (projected !== s) screensChanged = true;
911
+ return projected;
912
+ });
913
+ if (nextScreens.length !== screens.length) screensChanged = true;
914
+
915
+ const originalNavs = navs ?? [];
916
+ const filteredNavs = originalNavs.filter(
917
+ (nav) => !droppedNavQns.has(qualifyEntityName(feature.featureName, "nav", nav.id)),
918
+ );
919
+ let anyNavProjected = false;
920
+ const projectedNavs = filteredNavs.map((nav) => {
921
+ const next = projectNav(nav, keptScreenQns);
922
+ if (next !== nav) anyNavProjected = true;
923
+ return next;
924
+ });
925
+ const navsChanged = filteredNavs.length !== originalNavs.length || anyNavProjected;
926
+ const finalNavs = projectedNavs.length > 0 ? projectedNavs : undefined;
927
+
928
+ const nextContentCollections = projectContentCollections(
929
+ contentCollections,
930
+ roles,
931
+ survivingNavQns,
932
+ keptScreenQns,
933
+ );
934
+ const nextFeatureWorkspaces = projectWorkspaces(workspaces, roles, survivingNavQns);
935
+
936
+ const contentCollectionsChanged = nextContentCollections !== contentCollections;
937
+ const featureWorkspacesChanged = nextFeatureWorkspaces !== workspaces;
938
+
939
+ if (!screensChanged && !navsChanged && !contentCollectionsChanged && !featureWorkspacesChanged) {
940
+ return feature;
941
+ }
942
+
943
+ return {
944
+ ...rest,
945
+ screens: nextScreens,
946
+ ...(finalNavs !== undefined && { navs: finalNavs }),
947
+ ...(nextContentCollections !== undefined && { contentCollections: nextContentCollections }),
948
+ ...(nextFeatureWorkspaces !== undefined && { workspaces: nextFeatureWorkspaces }),
949
+ };
950
+ }