adminizer 5.0.0-build.8 → 5.1.0-build.24

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 (232) hide show
  1. package/assets/abnfDiagram-VCTEODGH-DFY3Pztx.js +1 -0
  2. package/assets/add-31tS-g4V.js +1 -0
  3. package/assets/add-group-Cf3zT7WE.js +1 -0
  4. package/assets/add-user-C62wOna-.js +1 -0
  5. package/assets/ai-assistant/agent.es.js +28464 -0
  6. package/assets/app-DPmI-MvN.css +1 -0
  7. package/assets/app-vdHbjOQX.js +138 -0
  8. package/assets/arc-DP4o1zz3.js +1 -0
  9. package/assets/architectureDiagram-5GKGNRK7-BjXW5Zu0.js +36 -0
  10. package/assets/asmcrypto-DZeQH0LC.js +1 -0
  11. package/assets/blockDiagram-I7D4REHJ-BUPnDwN0.js +129 -0
  12. package/assets/c4Diagram-7LVT6UL2-Cmb_dAhd.js +38 -0
  13. package/assets/{catalog-BEvJZYJP.js → catalog-C-y5rMaG.js} +3 -3
  14. package/assets/channel-CkJRUsq_.js +1 -0
  15. package/assets/chunk-2Q5K7J3B-CmS-dWwQ.js +1 -0
  16. package/assets/chunk-5VM5RSS4-Cqs2Ow64.js +15 -0
  17. package/assets/chunk-F27PBJKO-BoMCpJA1.js +1 -0
  18. package/assets/chunk-IMKFNOWR-DlDcPk1x.js +231 -0
  19. package/assets/chunk-JWPE2WC7-DzL_SU8s.js +1 -0
  20. package/assets/chunk-POPQ4Y6H-Cwq2-Vb8.js +1 -0
  21. package/assets/chunk-SVP7TREG-BfRHY5FU.js +88 -0
  22. package/assets/chunk-TICWLB2K-CRP7rHkW.js +206 -0
  23. package/assets/chunk-XXDRQBXY-Bg2jg-98.js +1 -0
  24. package/assets/classDiagram-ZZMXUADV-D4EF3HGj.js +1 -0
  25. package/assets/classDiagram-v2-VYDZK3BY-D4EF3HGj.js +1 -0
  26. package/assets/controls/ckeditor.es.js +9382 -9320
  27. package/assets/controls/handsontable.es.js +11971 -11938
  28. package/assets/controls/jsoneditor.es.js +10442 -10233
  29. package/assets/controls/toast-ui.es.js +1308 -1279
  30. package/assets/cose-bilkent-JH36ORCC-CCJ0SGyO.js +1 -0
  31. package/assets/cynefin-OW5HDTMX-B2Jx5fw3.js +166 -0
  32. package/assets/cynefinDiagram-5FMLGOSQ-DuK0Lb62.js +62 -0
  33. package/assets/cytoscape.esm-DzSz-X2X.js +321 -0
  34. package/assets/dagre-GXQ25YYZ-DnOYexET.js +4 -0
  35. package/assets/dashboard-X8k4X6Sw.js +3 -0
  36. package/assets/debounce-B3mXI_sx.js +1 -0
  37. package/assets/defaultLocale-DX6XiGOO.js +1 -0
  38. package/assets/diagram-S7CK7UJ4-DDgPBHsK.js +30 -0
  39. package/assets/diagram-UQ7AKVKN-qlL0GZs8.js +41 -0
  40. package/assets/diagram-VSXAHHWV-CQbiU1_c.js +3 -0
  41. package/assets/diagram-VX7I27RA-DFDI9_au.js +24 -0
  42. package/assets/diagram-Z3DM3KII-B5C9dtmJ.js +24 -0
  43. package/assets/docs-BiZOD7PP.js +1 -0
  44. package/assets/ebnfDiagram-PWID7BFC-CLMUsXEI.js +1 -0
  45. package/assets/erDiagram-RLTQ6QDP-ax9Xol5-.js +99 -0
  46. package/assets/flowDiagram-HODETNUW-CvaH7l3S.js +168 -0
  47. package/assets/ganttDiagram-EL5Y4UJY-Cga5U19j.js +292 -0
  48. package/assets/gitGraphDiagram-WWUBYQGX-B6o1T3-d.js +106 -0
  49. package/assets/history-CVBJUNSQ.js +1 -0
  50. package/assets/infoDiagram-27XIBGKW-BpWxgBCE.js +2 -0
  51. package/assets/init-Gi6I4Gst.js +1 -0
  52. package/assets/init-user-xSvnFTF0.js +1 -0
  53. package/assets/isObject-DL-BuaBl.js +1 -0
  54. package/assets/ishikawaDiagram-5VMMS53U-zE83db4H.js +70 -0
  55. package/assets/journeyDiagram-3NMN7TZE-DZNVFqrX.js +139 -0
  56. package/assets/kanban-definition-UXKFOSKX-CqxNgk27.js +89 -0
  57. package/assets/katex-HP8lGamR.js +257 -0
  58. package/assets/layout-CN3kt_jt.js +1 -0
  59. package/assets/linear-nvkaQ6BA.js +1 -0
  60. package/assets/{list-DhK0McVn.js → list-CrIU2eZz.js} +2 -2
  61. package/assets/login-CX9xVjCn.js +1 -0
  62. package/assets/manifest.json +834 -58
  63. package/assets/media-manager-BfYftLIH.js +6 -0
  64. package/assets/mermaid.core-C0V1NaVJ.js +310 -0
  65. package/assets/mindmap-definition-YA3MSWOX-Bh7_d9f1.js +96 -0
  66. package/assets/module-DPEvnad3.js +1 -0
  67. package/assets/notification-Dqpt5AIZ.js +20 -0
  68. package/assets/ordinal-Cboi1Yqb.js +1 -0
  69. package/assets/pegDiagram-XKGWAZYB-DMnjm6Z1.js +1 -0
  70. package/assets/pieDiagram-E7YTZNPT-D-RWCgYb.js +39 -0
  71. package/assets/quadrantDiagram-AXDQQJYC-BNBGP7RM.js +7 -0
  72. package/assets/railroadDiagram-O6MQD6OU-BIjVPT01.js +1 -0
  73. package/assets/register-CF4KQ97e.js +1 -0
  74. package/assets/requirementDiagram-BXWQKSXE-VPrtGdjM.js +84 -0
  75. package/assets/ru-CFJ4fysR.js +1 -0
  76. package/assets/sankeyDiagram-P5KCCOFB-ct9mLF3U.js +40 -0
  77. package/assets/sequenceDiagram-WJ2MYXX4-TC06Mbmw.js +162 -0
  78. package/assets/sizeCapture-INFHLROL-BMMwriCf.js +1 -0
  79. package/assets/stateDiagram-D77RDMKH-DtwYRPCX.js +1 -0
  80. package/assets/stateDiagram-v2-MP3YSRHH-C1yB0O5U.js +1 -0
  81. package/assets/swimlanes-42K2YHIH-DE3hRbbr.js +1 -0
  82. package/assets/swimlanesDiagram-VR7AAH4N-Ce4l1sQD.js +8 -0
  83. package/assets/timeline-definition-24CTP7MA-N7s_hfjj.js +120 -0
  84. package/assets/toNumber-DZb-r9sy.js +1 -0
  85. package/assets/use-appearance-DYTIKpT2.js +1 -0
  86. package/assets/user-filters-list-fR0YNJ3l.js +1 -0
  87. package/assets/vennDiagram-4TSXK5OY-Dj5bFQkR.js +34 -0
  88. package/assets/wardleyDiagram-VM6X3IG4-83ax25Z6.js +78 -0
  89. package/assets/welcome-v11MTsRK.js +1 -0
  90. package/assets/with-app-layout-CH6RIib1.js +31 -0
  91. package/assets/xychartDiagram-S5SC5T6Z-CW9Br5Xp.js +7 -0
  92. package/controllers/access-rights/permissionOptions.d.ts +2 -0
  93. package/controllers/access-rights/permissionOptions.js +23 -0
  94. package/controllers/add.js +2 -2
  95. package/controllers/addGroup.js +15 -6
  96. package/controllers/ai/AiAgentController.d.ts +66 -0
  97. package/controllers/ai/AiAgentController.js +435 -0
  98. package/controllers/ai/AiAssistantController.d.ts +0 -4
  99. package/controllers/ai/AiAssistantController.js +4 -78
  100. package/controllers/catalog/FrontendCatalogAdapter.js +7 -8
  101. package/controllers/docs/index.d.ts +22 -0
  102. package/controllers/docs/index.js +126 -0
  103. package/controllers/edit.js +2 -2
  104. package/controllers/editGroup.js +15 -6
  105. package/controllers/filter-fields/savedFilters.js +1 -1
  106. package/controllers/history-actions/HistoryController.js +7 -7
  107. package/controllers/inlineUpdate.js +10 -3
  108. package/controllers/list.js +1 -1
  109. package/controllers/login.js +1 -1
  110. package/controllers/notifications/NotificationController.js +14 -8
  111. package/controllers/view.js +3 -3
  112. package/controllers/widgets/filterInfo.js +1 -1
  113. package/helpers/accessRightsHelper.d.ts +30 -3
  114. package/helpers/accessRightsHelper.js +140 -12
  115. package/helpers/assetVersionHelper.d.ts +14 -0
  116. package/helpers/assetVersionHelper.js +42 -0
  117. package/helpers/configHelper.js +19 -18
  118. package/helpers/controllerHelper.d.ts +1 -0
  119. package/helpers/controllerHelper.js +25 -15
  120. package/helpers/fieldsHelper.d.ts +2 -1
  121. package/helpers/fieldsHelper.js +11 -5
  122. package/helpers/inertiaActionsHelper.d.ts +1 -1
  123. package/helpers/inertiaActionsHelper.js +4 -5
  124. package/helpers/inertiaAddHelper.d.ts +1 -1
  125. package/helpers/inertiaAddHelper.js +7 -6
  126. package/helpers/inertiaGroupHelper.js +10 -2
  127. package/helpers/inertiaListHelper.d.ts +1 -1
  128. package/helpers/inertiaListHelper.js +7 -8
  129. package/helpers/inertiaMenuHelper.d.ts +7 -2
  130. package/helpers/inertiaMenuHelper.js +12 -27
  131. package/helpers/menuHelper.d.ts +11 -1
  132. package/helpers/menuHelper.js +12 -0
  133. package/helpers/modelResourceHelper.d.ts +9 -0
  134. package/helpers/modelResourceHelper.js +43 -0
  135. package/helpers/navigationAccessHelper.d.ts +13 -0
  136. package/helpers/navigationAccessHelper.js +45 -0
  137. package/index.d.ts +11 -0
  138. package/index.js +11 -0
  139. package/interfaces/adminpanelConfig.d.ts +160 -2
  140. package/interfaces/queryCriteria.d.ts +13 -0
  141. package/interfaces/types.d.ts +169 -0
  142. package/lib/Adminizer.d.ts +8 -0
  143. package/lib/Adminizer.js +29 -2
  144. package/lib/DataAccessor.d.ts +100 -3
  145. package/lib/DataAccessor.js +454 -245
  146. package/lib/access-graph/AccessGraphResolver.d.ts +136 -0
  147. package/lib/access-graph/AccessGraphResolver.js +605 -0
  148. package/lib/access-graph/RecordAccessCache.d.ts +23 -0
  149. package/lib/access-graph/RecordAccessCache.js +30 -0
  150. package/lib/access-graph/RecordAccessResolver.d.ts +83 -0
  151. package/lib/access-graph/RecordAccessResolver.js +295 -0
  152. package/lib/access-graph/shared.d.ts +60 -0
  153. package/lib/access-graph/shared.js +121 -0
  154. package/lib/admin-links/AdminLinkHandler.d.ts +72 -0
  155. package/lib/admin-links/AdminLinkHandler.js +183 -0
  156. package/lib/ai-assistant/AbstractAiConversationHistoryService.d.ts +39 -0
  157. package/lib/ai-assistant/AbstractAiConversationHistoryService.js +98 -0
  158. package/lib/ai-assistant/AbstractAiModelService.d.ts +139 -2
  159. package/lib/ai-assistant/AbstractAiModelService.js +154 -1
  160. package/lib/ai-assistant/AiAgentRunManager.d.ts +35 -0
  161. package/lib/ai-assistant/AiAgentRunManager.js +109 -0
  162. package/lib/ai-assistant/AiAssistantAgentSkillHandler.d.ts +61 -0
  163. package/lib/ai-assistant/AiAssistantAgentSkillHandler.js +90 -0
  164. package/lib/ai-assistant/AiAssistantHandler.d.ts +1 -0
  165. package/lib/ai-assistant/AiAssistantHandler.js +4 -0
  166. package/lib/ai-assistant/AiAssistantUiMethodHandler.d.ts +60 -0
  167. package/lib/ai-assistant/AiAssistantUiMethodHandler.js +144 -0
  168. package/lib/ai-assistant/builtinAgentSkills.d.ts +9 -0
  169. package/lib/ai-assistant/builtinAgentSkills.js +259 -0
  170. package/lib/ai-assistant/jsonSafe.d.ts +13 -0
  171. package/lib/ai-assistant/jsonSafe.js +81 -0
  172. package/lib/app-manager/AdminizerApp.d.ts +31 -2
  173. package/lib/app-manager/AppManager.js +43 -42
  174. package/lib/app-manager/ConfigLayerHandler.js +4 -0
  175. package/lib/docs/AbstractDocumentation.d.ts +100 -0
  176. package/lib/docs/AbstractDocumentation.js +108 -0
  177. package/lib/docs/DocumentationHandler.d.ts +100 -0
  178. package/lib/docs/DocumentationHandler.js +198 -0
  179. package/lib/docs/FileDocumentation.d.ts +47 -0
  180. package/lib/docs/FileDocumentation.js +189 -0
  181. package/lib/docs/docsAgentSkills.d.ts +10 -0
  182. package/lib/docs/docsAgentSkills.js +93 -0
  183. package/lib/filters/FilterService.js +4 -3
  184. package/lib/history-actions/AbstractHistoryAdapter.d.ts +26 -1
  185. package/lib/history-actions/AbstractHistoryAdapter.js +155 -25
  186. package/lib/media-manager/helpers/MediaManagerHelper.js +9 -7
  187. package/lib/model/AbstractModel.d.ts +48 -0
  188. package/lib/model/AbstractModel.js +14 -4
  189. package/lib/model/ModelHandler.d.ts +50 -2
  190. package/lib/model/ModelHandler.js +117 -8
  191. package/lib/model/adapter/sequelize.d.ts +43 -3
  192. package/lib/model/adapter/sequelize.js +317 -138
  193. package/lib/model/adapter/typeorm.d.ts +16 -0
  194. package/lib/model/adapter/typeorm.js +136 -9
  195. package/lib/notifications/GeneralNotificationService.js +6 -5
  196. package/lib/notifications/SystemNotificationService.js +1 -1
  197. package/lib/ui-i18n/uiTranslationKeys.d.ts +2 -1
  198. package/lib/ui-i18n/uiTranslationKeys.js +42 -1
  199. package/lib/widgets/widgetHandler.js +10 -6
  200. package/models/Group.d.ts +2 -1
  201. package/package.json +35 -23
  202. package/policies/authPolicies.js +6 -6
  203. package/policies/permissionResolvers.d.ts +1 -0
  204. package/policies/permissionResolvers.js +3 -0
  205. package/system/Router.js +21 -3
  206. package/system/bindAccessRights.js +13 -3
  207. package/system/bindDocs.d.ts +13 -0
  208. package/system/bindDocs.js +77 -0
  209. package/system/bindInertia.js +30 -7
  210. package/system/buildInternalModelAccess.d.ts +8 -0
  211. package/system/buildInternalModelAccess.js +36 -4
  212. package/system/defaults.js +7 -1
  213. package/system/validateSystemModels.js +29 -9
  214. package/assets/add-DED_wpga.js +0 -1
  215. package/assets/add-group-CL80RdYb.js +0 -1
  216. package/assets/add-user-BRmwvQB8.js +0 -1
  217. package/assets/app-DNXiApCk.css +0 -1
  218. package/assets/app-layout-86yxiZOy.js +0 -1
  219. package/assets/app.js +0 -138
  220. package/assets/dashboard-ZP6K2s_8.js +0 -3
  221. package/assets/debounce-BMmXVQ06.js +0 -1
  222. package/assets/history-M61Bcv02.js +0 -1
  223. package/assets/init-user-8aUohVDq.js +0 -1
  224. package/assets/login-CO4laf6m.js +0 -1
  225. package/assets/media-manager-BDbpSSjH.js +0 -6
  226. package/assets/module-DfBneaUl.js +0 -1
  227. package/assets/notification-CVnQf3ef.js +0 -20
  228. package/assets/register-DBYIMXGQ.js +0 -1
  229. package/assets/ru-rYKdQbWF.js +0 -1
  230. package/assets/use-appearance-_EbbjHTh.js +0 -1
  231. package/assets/user-filters-list-DXhGLFTn.js +0 -1
  232. package/assets/welcome-CLxvXkcg.js +0 -1
@@ -0,0 +1,605 @@
1
+ import { Adminizer } from "../Adminizer.js";
2
+ import { RecordAccessCache } from "./RecordAccessCache.js";
3
+ import { grantsToken, membershipTargetIds, modelCrudToken, relationValueId, resolveMembership, } from "./shared.js";
4
+ function normalizeName(name) {
5
+ return name.toLowerCase();
6
+ }
7
+ /**
8
+ * A configured `pushdown: true` that cannot be compiled is invisible: the records returned are
9
+ * the same, the read is just back to one query per level with every intermediate id inlined. So
10
+ * the fallback is the one thing worth saying out loud — `warn`, not `error`, because nothing is
11
+ * broken and access stays correct; and once per model, because it repeats on every request.
12
+ */
13
+ function reportPushdownFallback(graph, resourceName, reason) {
14
+ const reported = graph.pushdownFallbacksReported ??= new Set();
15
+ const key = normalizeName(resourceName);
16
+ if (reported.has(key)) {
17
+ return;
18
+ }
19
+ reported.add(key);
20
+ Adminizer.log.warn(`[accessGraph] graph "${graph.key}": "${resourceName}" declares pushdown: true, but its read filter `
21
+ + `cannot be compiled into a subquery — ${reason ?? "reason not reported by the adapter"}. `
22
+ + `Falling back to per-level materialization: the records are the same, but the read costs one query `
23
+ + `per level of the chain plus every intermediate id inlined into the final query.`);
24
+ }
25
+ /**
26
+ * Compiled per model registry, merged-config object and model-registry version. The cache
27
+ * is keyed by the `ModelHandler` — the one object that is never shared between Adminizer
28
+ * instances — so two instances sharing a config object cannot read each other's compile.
29
+ * `ConfigLayerHandler.rebuildConfig` replaces `config.accessGraph` wholesale (identity
30
+ * mismatch), and every model (un)registration or enable/disable bumps
31
+ * `ModelHandler.registryVersion` (version mismatch) — either way the stale compile is
32
+ * dropped, so a model registered without any config change joins the graph too.
33
+ */
34
+ const compiledGraphCache = new WeakMap();
35
+ export function compileAccessGraph(adminizer) {
36
+ const graphsConfig = adminizer.config?.accessGraph;
37
+ if (!graphsConfig || !Object.keys(graphsConfig).length) {
38
+ return undefined;
39
+ }
40
+ const modelHandler = adminizer.modelHandler;
41
+ const version = modelHandler.registryVersion;
42
+ let entry = compiledGraphCache.get(modelHandler);
43
+ if (!entry || entry.graphsConfig !== graphsConfig || entry.version !== version) {
44
+ entry = { graphsConfig, version, compiled: buildCompiledGraph(graphsConfig, modelHandler) };
45
+ compiledGraphCache.set(modelHandler, entry);
46
+ Adminizer.log.warn(`[accessGraph] accessGraph is an EXPERIMENTAL feature and is enabled for graph(s): `
47
+ + `${Object.keys(graphsConfig).join(", ")}. Its configuration format and access semantics `
48
+ + `may change in a minor release. Review the resulting record visibility before relying on it in production.`);
49
+ for (const problem of entry.compiled.problems) {
50
+ Adminizer.log.error(`[accessGraph] ${problem}`);
51
+ }
52
+ // Two conflicting record-access rules on one model. The graph wins, so the model's
53
+ // own declaration silently does nothing — which means the records it exposes are not
54
+ // the ones its config describes. That is a configuration error, not a preference:
55
+ // report it as loudly as a structural problem, even though the graph still filters
56
+ // correctly and access does not fail open.
57
+ for (const node of entry.compiled.nodes.values()) {
58
+ if (adminizer.config?.models?.[node.resourceName]?.userAccessRelation) {
59
+ Adminizer.log.error(`[accessGraph] INVALID CONFIGURATION — model "${node.resourceName}" declares ` +
60
+ `"userAccessRelation" AND is covered by access graph "${node.graphKey}". ` +
61
+ `Two record-access rules on one model must never coexist: the graph wins, the model's ` +
62
+ `own "userAccessRelation" is IGNORED, and the records this model exposes are therefore ` +
63
+ `NOT the ones its own declaration describes. Resolve this now — either remove ` +
64
+ `"userAccessRelation" from the "${node.resourceName}" model config (the graph already ` +
65
+ `confines it), or take "${node.resourceName}" out of the "${node.graphKey}" graph's ` +
66
+ `"include" if it must keep its own rule.`);
67
+ }
68
+ }
69
+ }
70
+ return entry.compiled;
71
+ }
72
+ /**
73
+ * True when `fieldKey` on `resourceName` is exactly the edge the graph uses to reach that
74
+ * model's parent. Records behind such an edge need no verification: a child is visible
75
+ * only because its parent is, so the parent is inside the user's graph by construction.
76
+ */
77
+ export function isGraphParentEdge(adminizer, resourceName, fieldKey, targetResourceName) {
78
+ const node = compileAccessGraph(adminizer)?.nodes.get(normalizeName(resourceName));
79
+ if (!node || node.error || node.isRoot || !node.parentResource) {
80
+ return false;
81
+ }
82
+ return node.parentAlias === fieldKey
83
+ && normalizeName(node.parentResource) === normalizeName(targetResourceName);
84
+ }
85
+ /** Startup validation: fails loud on structural graph errors. */
86
+ export function validateAccessGraph(adminizer) {
87
+ const compiled = compileAccessGraph(adminizer);
88
+ if (compiled?.problems.length) {
89
+ throw new Error(`Invalid accessGraph configuration:\n - ${compiled.problems.join("\n - ")}`);
90
+ }
91
+ }
92
+ /**
93
+ * Models the resolver queries through the internal `data-accessor` scope at request
94
+ * time: membership models, `Group` (when narrowing by membership group), and every
95
+ * model that is a parent of another graph model (their id lists feed the next level).
96
+ * Roots are never queried — their ids come from the membership rows.
97
+ */
98
+ export function collectAccessGraphInternalModels(graphsConfig, modelHandler) {
99
+ const models = [];
100
+ for (const graphConfig of Object.values(graphsConfig ?? {})) {
101
+ if (!graphConfig || typeof graphConfig !== "object") {
102
+ continue;
103
+ }
104
+ // Must mirror what the resolver queries at request time: `resolveMembership`
105
+ // resolves `through` as a resource name/alias (getResourceRecord), and the
106
+ // group narrowing always asks for the literal "Group".
107
+ const membership = graphConfig.membership;
108
+ if (membership?.through) {
109
+ const throughResource = modelHandler.getResourceRecord(membership.through)?.name;
110
+ if (throughResource) {
111
+ models.push(throughResource);
112
+ }
113
+ if (membership.group) {
114
+ models.push("Group");
115
+ }
116
+ }
117
+ const rootResource = graphConfig.root
118
+ ? modelHandler.getResourceRecord(graphConfig.root)?.name
119
+ : undefined;
120
+ for (const [modelName, edge] of Object.entries(graphConfig.include ?? {})) {
121
+ const record = modelHandler.getResourceRecord(modelName);
122
+ const attribute = edge?.parent ? record?.model.attributes?.[edge.parent] : undefined;
123
+ const parentResource = attribute?.model
124
+ ? modelHandler.resolveAssociationResource(attribute.model, attribute.resourceName)
125
+ : undefined;
126
+ if (parentResource && parentResource !== rootResource) {
127
+ models.push(parentResource);
128
+ }
129
+ }
130
+ }
131
+ return Array.from(new Set(models));
132
+ }
133
+ function buildCompiledGraph(graphsConfig, modelHandler) {
134
+ const graphs = new Map();
135
+ const nodes = new Map();
136
+ const problems = [];
137
+ const addNode = (node) => {
138
+ const id = normalizeName(node.resourceName);
139
+ const existing = nodes.get(id);
140
+ if (existing) {
141
+ existing.error =
142
+ `Model "${node.resourceName}" belongs to more than one access graph ` +
143
+ `("${existing.graphKey}", "${node.graphKey}")`;
144
+ problems.push(existing.error);
145
+ return;
146
+ }
147
+ nodes.set(id, node);
148
+ };
149
+ for (const [graphKey, graphConfig] of Object.entries(graphsConfig)) {
150
+ if (!graphConfig || typeof graphConfig !== "object") {
151
+ continue;
152
+ }
153
+ const rootRecord = graphConfig.root ? modelHandler.getResourceRecord(graphConfig.root) : undefined;
154
+ const graph = {
155
+ key: graphKey,
156
+ rootResource: rootRecord?.name,
157
+ rootPrimaryKey: rootRecord?.model.primaryKey ?? "id",
158
+ bypassToken: graphConfig.bypassToken,
159
+ pushdown: graphConfig.pushdown,
160
+ resolveGraphRootIds: graphConfig.resolveGraphRootIds,
161
+ };
162
+ graphs.set(graphKey, graph);
163
+ const failGraph = (message) => {
164
+ graph.error = `graph "${graphKey}": ${message}`;
165
+ problems.push(graph.error);
166
+ };
167
+ if (!graphConfig.root) {
168
+ failGraph(`no root model is declared (an extend-only patch without its defining graph?)`);
169
+ }
170
+ else if (!rootRecord) {
171
+ // The graph covers registered models, so a missing root must fail closed, not open.
172
+ failGraph(`root model "${graphConfig.root}" is not registered`);
173
+ }
174
+ else {
175
+ addNode({
176
+ graphKey,
177
+ resourceName: rootRecord.name,
178
+ isRoot: true,
179
+ primaryKey: graph.rootPrimaryKey,
180
+ });
181
+ }
182
+ if (!graph.error) {
183
+ if (graphConfig.membership) {
184
+ try {
185
+ graph.membership = resolveGraphMembership(graphKey, graphConfig.membership, graph.rootResource, modelHandler);
186
+ }
187
+ catch (error) {
188
+ graph.error = error.message;
189
+ problems.push(graph.error);
190
+ }
191
+ }
192
+ else if (!graphConfig.resolveGraphRootIds) {
193
+ failGraph(`neither "membership" nor "resolveGraphRootIds" is declared`);
194
+ }
195
+ }
196
+ for (const [modelName, edge] of Object.entries(graphConfig.include ?? {})) {
197
+ const record = modelHandler.getResourceRecord(modelName);
198
+ if (!record) {
199
+ // An unregistered model is unreachable through the panel, so staying
200
+ // outside the graph is safe; registering it bumps the registry version, which
201
+ // recompiles the graph, so the edge activates immediately.
202
+ Adminizer.log.warn(`[accessGraph] graph "${graphKey}": model "${modelName}" is not registered; ` +
203
+ `it stays outside the graph until it appears`);
204
+ continue;
205
+ }
206
+ const node = {
207
+ graphKey,
208
+ resourceName: record.name,
209
+ isRoot: false,
210
+ parentAlias: edge?.parent,
211
+ primaryKey: record.model.primaryKey ?? "id",
212
+ };
213
+ addNode(node);
214
+ if (nodes.get(normalizeName(record.name)) !== node) {
215
+ continue; // duplicated across graphs — already failed closed
216
+ }
217
+ if (!edge?.parent) {
218
+ node.error = `graph "${graphKey}": model "${record.name}" declares no "parent" edge`;
219
+ problems.push(node.error);
220
+ continue;
221
+ }
222
+ const attribute = record.model.attributes?.[edge.parent];
223
+ if (!attribute?.model) {
224
+ node.error = `graph "${graphKey}": edge "${record.name}.${edge.parent}" is not a model association`;
225
+ problems.push(node.error);
226
+ continue;
227
+ }
228
+ // Neither adapter marks an association attribute as required — the NOT NULL
229
+ // lives on its foreign-key column, so the edge is mandatory when either says so.
230
+ const edgeForeignKey = typeof attribute.via === "string"
231
+ ? record.model.attributes?.[attribute.via]
232
+ : undefined;
233
+ if (attribute.required !== true && edgeForeignKey?.required !== true) {
234
+ // Not an error: partial updates legitimately omit the parent. But a record
235
+ // created without one hangs outside every graph and only administrators
236
+ // will ever see it again, which looks like data loss from the panel.
237
+ Adminizer.log.warn(`[accessGraph] graph "${graphKey}": edge "${record.name}.${edge.parent}" is optional — ` +
238
+ `records saved without it stay outside the graph and are invisible to non-administrators`);
239
+ }
240
+ node.parentResource = modelHandler.resolveAssociationResource(attribute.model, attribute.resourceName);
241
+ if (!node.parentResource) {
242
+ // Unlike a missing include model, this one IS reachable through the panel,
243
+ // so an unresolved parent must deny access rather than leave it outside the graph.
244
+ node.error =
245
+ `graph "${graphKey}": edge "${record.name}.${edge.parent}" targets "${attribute.model}", ` +
246
+ `which is not registered`;
247
+ problems.push(node.error);
248
+ }
249
+ }
250
+ // Stage 2: graphRootField declarations shortcut reads but never replace the edge itself,
251
+ // so a bad declaration is a config problem (fail loud at boot), not a denied node —
252
+ // the chain keeps filtering.
253
+ for (const [modelName, field] of Object.entries(graphConfig.graphRootField ?? {})) {
254
+ const record = modelHandler.getResourceRecord(modelName);
255
+ if (!record) {
256
+ continue; // unregistered include model — same fail-soft as above
257
+ }
258
+ const node = nodes.get(normalizeName(record.name));
259
+ if (!node || node.graphKey !== graphKey || node.isRoot) {
260
+ problems.push(`graph "${graphKey}": graphRootField declared for "${modelName}", ` +
261
+ `which is not an included (non-root) model of the graph`);
262
+ continue;
263
+ }
264
+ if (!record.model.attributes?.[field]) {
265
+ problems.push(`graph "${graphKey}": graphRootField "${record.name}.${field}" does not exist on the model`);
266
+ continue;
267
+ }
268
+ node.graphRootField = field;
269
+ }
270
+ }
271
+ // Every non-root node must reach its graph's root through same-graph nodes, acyclically.
272
+ for (const node of nodes.values()) {
273
+ if (node.isRoot || node.error) {
274
+ continue;
275
+ }
276
+ const visited = new Set([normalizeName(node.resourceName)]);
277
+ let current = node;
278
+ for (;;) {
279
+ const parentId = normalizeName(current.parentResource);
280
+ const parent = nodes.get(parentId);
281
+ if (!parent || parent.graphKey !== node.graphKey) {
282
+ node.error =
283
+ `graph "${node.graphKey}": edge "${current.resourceName}.${current.parentAlias}" points at ` +
284
+ `"${current.parentResource}", which is not part of the graph`;
285
+ problems.push(node.error);
286
+ break;
287
+ }
288
+ if (parent.error) {
289
+ node.error =
290
+ `graph "${node.graphKey}": model "${node.resourceName}" is unreachable because its ` +
291
+ `ancestor "${parent.resourceName}" is misconfigured`;
292
+ problems.push(node.error);
293
+ break;
294
+ }
295
+ if (parent.isRoot) {
296
+ break;
297
+ }
298
+ if (visited.has(parentId)) {
299
+ node.error = `graph "${node.graphKey}": cycle detected through "${parent.resourceName}"`;
300
+ problems.push(node.error);
301
+ break;
302
+ }
303
+ visited.add(parentId);
304
+ current = parent;
305
+ }
306
+ }
307
+ return { graphs, nodes, problems };
308
+ }
309
+ /**
310
+ * Resolves the membership declared at the graph root against the registered models:
311
+ * `through` must carry exactly one relation to the root model, a `via` relation to User
312
+ * and, optionally, a `group` relation to Group. Same rules as the membership form of
313
+ * `userAccessRelation`, just anchored at the root instead of a `field`.
314
+ */
315
+ function resolveGraphMembership(graphKey, membershipConfig, rootResource, modelHandler) {
316
+ return resolveMembership(membershipConfig, rootResource, modelHandler, (message) => new Error(`graph "${graphKey}": ${message}`));
317
+ }
318
+ /**
319
+ * Short-lived resolver: one instance is held by a `DataAccessor` (model + action), so
320
+ * its memoized intermediate id lists (key: graph + verb + token model + level) live as
321
+ * long as the owning accessor — never longer than the request, though a request may
322
+ * hold several accessors. Nothing is cached across requests — staleness is more
323
+ * dangerous than the saved queries.
324
+ */
325
+ export class AccessGraphResolver {
326
+ adminizer;
327
+ user;
328
+ actionVerb;
329
+ /** Shared with the other resolvers of the same request when the caller supplies one. */
330
+ cache;
331
+ constructor(adminizer, user, actionVerb, cache) {
332
+ this.adminizer = adminizer;
333
+ this.user = user;
334
+ this.actionVerb = actionVerb;
335
+ this.cache = cache ?? new RecordAccessCache();
336
+ }
337
+ /** Cache keys carry the user: a shared cache is per request, but never per user by construction. */
338
+ cacheKey(suffix) {
339
+ return `graph:${String(this.user.id)}:${suffix}`;
340
+ }
341
+ /** True when the compiled graph covers the model (including fail-closed misconfigured nodes). */
342
+ static covers(adminizer, resourceName) {
343
+ return Boolean(compileAccessGraph(adminizer)?.nodes.has(normalizeName(resourceName)));
344
+ }
345
+ /**
346
+ * Flat where-fragment confining the model to the records reachable from the user's
347
+ * memberships at the root: root ids for the root model, `{parentAlias: {in: ids}}`
348
+ * for everything below it. `{}` means no constraint (bypass or `"all"`).
349
+ */
350
+ async buildWhere(resourceName) {
351
+ const { node, graph } = this.requireNode(resourceName);
352
+ if (this.hasGraphBypass(graph)) {
353
+ return {};
354
+ }
355
+ // The filter attribute: root — its pk; graphRootField (stage 2) — the denormalized
356
+ // root-id column; otherwise the parent edge.
357
+ const attribute = node.isRoot ? node.primaryKey : (node.graphRootField ?? node.parentAlias);
358
+ if (graph.pushdown) {
359
+ let declined;
360
+ const operand = await this.compilePushdownOperand(node, graph, resourceName, (reason) => { declined ??= reason; });
361
+ if (operand === "unfiltered") {
362
+ return {};
363
+ }
364
+ if (operand !== undefined) {
365
+ return { [attribute]: { in: operand } };
366
+ }
367
+ // undefined — the chain cannot be pushed down; fall back to materialization
368
+ reportPushdownFallback(graph, resourceName, declined);
369
+ }
370
+ const sourceIds = node.isRoot || node.graphRootField
371
+ ? await this.visibleModelIds(graph.rootResource, graph, resourceName)
372
+ : await this.visibleModelIds(node.parentResource, graph, resourceName);
373
+ return sourceIds === "all" ? {} : { [attribute]: { in: sourceIds } };
374
+ }
375
+ /**
376
+ * Stage 3: compiles the whole walk — membership subquery plus the parent chain (cut by
377
+ * a graphRootField when one is on the path) — into one adapter-emitted subquery operand.
378
+ * Returns `"unfiltered"` for an explicit `"all"`, or `undefined` when the chain cannot
379
+ * be pushed down (custom-resolved ids with no chain still return a plain id array).
380
+ */
381
+ async compilePushdownOperand(node, graph, tokenModelName, decline) {
382
+ // resolveGraphRootIds takes precedence over membership; its result is always materialized
383
+ let seedIds;
384
+ if (graph.resolveGraphRootIds) {
385
+ const resolved = await this.customGraphRootIds(graph);
386
+ if (resolved === "all") {
387
+ return "unfiltered";
388
+ }
389
+ if (resolved !== undefined) {
390
+ seedIds = Array.from(new Set(resolved));
391
+ }
392
+ }
393
+ let membershipLevel;
394
+ if (seedIds === undefined) {
395
+ const membership = graph.membership;
396
+ if (!membership) {
397
+ // the materialized path surfaces the configuration error
398
+ decline("the graph declares no membership to start the subquery from");
399
+ return undefined;
400
+ }
401
+ const throughModel = this.adminizer.modelHandler.getResource(membership.through);
402
+ if (!throughModel) {
403
+ decline(`the membership model "${membership.through}" is not registered`);
404
+ return undefined;
405
+ }
406
+ const conditions = [
407
+ { attribute: membership.viaAlias, values: [this.user.id] },
408
+ ];
409
+ if (membership.groupAlias) {
410
+ conditions.push({
411
+ attribute: membership.groupAlias,
412
+ values: await this.allGroupIdsGrantingAction(tokenModelName),
413
+ });
414
+ }
415
+ membershipLevel = { model: throughModel, select: membership.targetAlias, conditions };
416
+ }
417
+ // Parent chain from the source down to the filtered attribute's target level;
418
+ // empty for the root and for graphRootField models (their filter reads root ids directly).
419
+ const compiled = compileAccessGraph(this.adminizer);
420
+ const chain = [];
421
+ if (!node.isRoot && !node.graphRootField) {
422
+ let current = compiled.nodes.get(normalizeName(node.parentResource));
423
+ for (;;) {
424
+ chain.unshift(current);
425
+ if (current.isRoot || current.graphRootField) {
426
+ break;
427
+ }
428
+ current = compiled.nodes.get(normalizeName(current.parentResource));
429
+ }
430
+ }
431
+ const levels = [];
432
+ let innerIsSubquery = false;
433
+ if (membershipLevel) {
434
+ levels.push(membershipLevel);
435
+ innerIsSubquery = true;
436
+ }
437
+ for (let i = 0; i < chain.length; i++) {
438
+ const levelNode = chain[i];
439
+ if (levelNode.isRoot) {
440
+ continue; // its ids ARE the inner source (membership subquery or seed ids)
441
+ }
442
+ const levelModel = this.adminizer.modelHandler.getResource(levelNode.resourceName);
443
+ if (!levelModel) {
444
+ decline(`the chain passes through "${levelNode.resourceName}", which is not registered`);
445
+ return undefined;
446
+ }
447
+ const linkAttribute = i === 0 && levelNode.graphRootField ? levelNode.graphRootField : levelNode.parentAlias;
448
+ const level = { model: levelModel, select: levelNode.primaryKey };
449
+ if (innerIsSubquery) {
450
+ level.parentAttribute = linkAttribute;
451
+ }
452
+ else {
453
+ level.conditions = [{ attribute: linkAttribute, values: seedIds }];
454
+ }
455
+ levels.push(level);
456
+ innerIsSubquery = true;
457
+ }
458
+ if (!levels.length) {
459
+ // Custom-resolved ids with nothing between them and the filter — plain IN list.
460
+ return seedIds;
461
+ }
462
+ const accessedModel = this.adminizer.modelHandler.getResource(node.resourceName);
463
+ if (!accessedModel?.compileGraphInSubquery) {
464
+ decline(`the adapter serving "${node.resourceName}" implements no pushdown`);
465
+ return undefined;
466
+ }
467
+ return accessedModel.compileGraphInSubquery(levels, decline);
468
+ }
469
+ /**
470
+ * Write path: the chosen parent must be reachable for this action's verb (with a
471
+ * membership `group` declared, through a group carrying the action's CRUD token).
472
+ */
473
+ async validateWriteRelation(resourceName, record) {
474
+ const { node, graph } = this.requireNode(resourceName);
475
+ if (node.isRoot) {
476
+ return;
477
+ }
478
+ if (this.hasGraphBypass(graph)) {
479
+ return;
480
+ }
481
+ const data = record;
482
+ if (node.parentAlias) {
483
+ const value = data[node.parentAlias];
484
+ if (value !== undefined && value !== null) {
485
+ const parentIds = await this.visibleModelIds(node.parentResource, graph, resourceName);
486
+ if (parentIds !== "all") {
487
+ const parentNode = this.requireNode(node.parentResource).node;
488
+ const chosenId = relationValueId(value, parentNode.primaryKey);
489
+ if (!parentIds.some((id) => String(id) === String(chosenId))) {
490
+ throw new Error(`Access denied: "${node.parentAlias}" does not belong to the current user`);
491
+ }
492
+ }
493
+ }
494
+ }
495
+ // graphRootField is an ordinary writable column and the read filter trusts it,
496
+ // so a forged value would surface the record in another graph — it must
497
+ // stay within the root ids reachable for this action's verb.
498
+ if (node.graphRootField) {
499
+ const value = data[node.graphRootField];
500
+ if (value !== undefined && value !== null) {
501
+ const rootIds = await this.visibleModelIds(graph.rootResource, graph, resourceName);
502
+ if (rootIds !== "all") {
503
+ const chosenId = relationValueId(value, graph.rootPrimaryKey);
504
+ if (!rootIds.some((id) => String(id) === String(chosenId))) {
505
+ throw new Error(`Access denied: "${node.graphRootField}" does not belong to the current user`);
506
+ }
507
+ }
508
+ }
509
+ }
510
+ }
511
+ requireNode(resourceName) {
512
+ const compiled = compileAccessGraph(this.adminizer);
513
+ const node = compiled?.nodes.get(normalizeName(resourceName));
514
+ if (!node) {
515
+ throw new Error(`Model "${resourceName}" is not covered by accessGraph`);
516
+ }
517
+ const graph = compiled.graphs.get(node.graphKey);
518
+ if (!graph) {
519
+ throw new Error(`access graph "${node.graphKey}" was not compiled`);
520
+ }
521
+ // The graph-level problem is the root cause; node errors are often its fallout.
522
+ if (graph.error) {
523
+ throw new Error(graph.error);
524
+ }
525
+ if (node.error) {
526
+ throw new Error(node.error);
527
+ }
528
+ return { node, graph };
529
+ }
530
+ /** Ids of the model's records visible to the user, or `"all"`; memoized per level. */
531
+ visibleModelIds(resourceName, graph, tokenModelName) {
532
+ const memoKey = this.cacheKey(`${graph.key}:${this.actionVerb}:${normalizeName(tokenModelName)}:${normalizeName(resourceName)}`);
533
+ return this.cache.resolve(memoKey, () => this.computeVisibleModelIds(resourceName, graph, tokenModelName));
534
+ }
535
+ async computeVisibleModelIds(resourceName, graph, tokenModelName) {
536
+ const { node } = this.requireNode(resourceName);
537
+ if (node.isRoot) {
538
+ return this.resolveGraphRootIds(graph, tokenModelName);
539
+ }
540
+ // Stage 2: the denormalized root-id column collapses this level to one step.
541
+ const sourceIds = node.graphRootField
542
+ ? await this.visibleModelIds(graph.rootResource, graph, tokenModelName)
543
+ : await this.visibleModelIds(node.parentResource, graph, tokenModelName);
544
+ if (sourceIds === "all") {
545
+ return "all";
546
+ }
547
+ const rows = await this.internalModel(node.resourceName)
548
+ .find({ where: { [node.graphRootField ?? node.parentAlias]: { in: sourceIds } } }) ?? [];
549
+ return Array.from(new Set(rows
550
+ .map((row) => row[node.primaryKey])
551
+ .filter((id) => id !== null && id !== undefined)));
552
+ }
553
+ /**
554
+ * Root ids the user reaches: `resolveGraphRootIds` when declared (undefined falls back),
555
+ * otherwise the membership rows — narrowed, when `group` is declared, to memberships
556
+ * whose group carries this action's CRUD token for the model being accessed.
557
+ */
558
+ async resolveGraphRootIds(graph, tokenModelName) {
559
+ if (graph.resolveGraphRootIds) {
560
+ const resolved = await this.customGraphRootIds(graph);
561
+ if (resolved === "all") {
562
+ return "all";
563
+ }
564
+ if (resolved !== undefined) {
565
+ return Array.from(new Set(resolved));
566
+ }
567
+ }
568
+ const membership = graph.membership;
569
+ if (!membership) {
570
+ throw new Error(`access graph "${graph.key}": resolveGraphRootIds returned undefined and no membership is declared`);
571
+ }
572
+ return membershipTargetIds((resourceName) => this.internalModel(resourceName), membership, this.user.id, graph.rootPrimaryKey, modelCrudToken(this.actionVerb, tokenModelName));
573
+ }
574
+ /** `resolveGraphRootIds` may be side-effectful; call it at most once per graph per request. */
575
+ customGraphRootIds(graph) {
576
+ return this.cache.resolve(this.cacheKey(`custom-graph-ids:${graph.key}:${this.actionVerb}`), async () => graph.resolveGraphRootIds(this.user, this.actionVerb));
577
+ }
578
+ /**
579
+ * Pushdown variant of the group narrowing: ids of every group granting this action's
580
+ * CRUD token for the accessed model, computed in JS (token grants are JSON) and
581
+ * injected into the membership subquery as a literal IN-list. The group table is a
582
+ * small set of role definitions, so fetching it whole is cheaper than a round trip
583
+ * for the membership rows the JS path would need.
584
+ */
585
+ async allGroupIdsGrantingAction(tokenModelName) {
586
+ const tokenId = modelCrudToken(this.actionVerb, tokenModelName);
587
+ return this.cache.resolve(this.cacheKey(`granting-groups:${tokenId}`), async () => {
588
+ const groups = await this.internalModel("Group").find({});
589
+ return (groups ?? [])
590
+ .filter((group) => grantsToken(group.tokens ?? [], tokenId))
591
+ .map((group) => group.id);
592
+ });
593
+ }
594
+ /** The bypass token is matched against the token grants of the user's global groups. */
595
+ hasGraphBypass(graph) {
596
+ if (!graph.bypassToken) {
597
+ return false;
598
+ }
599
+ const tokenId = graph.bypassToken.toLowerCase();
600
+ return (this.user.groups ?? []).some((group) => grantsToken(group.tokens ?? [], tokenId));
601
+ }
602
+ internalModel(resourceName) {
603
+ return this.adminizer.modelHandler.internal("data-accessor").get(resourceName);
604
+ }
605
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Memoization shared by every record-access resolver of one request.
3
+ *
4
+ * A single page builds several `DataAccessor`s — the list itself plus one per association
5
+ * dropdown — and each would otherwise repeat the same membership and group lookups. The
6
+ * cache is deliberately short-lived: it is created per request (or per accessor, when the
7
+ * caller does not supply one) and never outlives it, because a stale access decision is far more
8
+ * dangerous than a repeated query.
9
+ *
10
+ * Every key carries the user id and the action verb, so sharing one instance between the
11
+ * accessors of a page can never hand one user's decision to another, nor a read decision to
12
+ * a write. What is stored is the promise rather than its value: concurrent callers coalesce
13
+ * onto a single lookup, and a rejection likewise stays cached for the rest of the request.
14
+ */
15
+ export declare class RecordAccessCache {
16
+ private readonly entries;
17
+ /**
18
+ * Runs `factory` once per key; every later caller awaits the promise of the first one.
19
+ * Keys are opaque strings owned by the resolvers, which prefix them by kind (`membership:`,
20
+ * `graph:`) to keep the namespaces of the two record-access paths apart.
21
+ */
22
+ resolve<T>(key: string, factory: () => Promise<T>): Promise<T>;
23
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Memoization shared by every record-access resolver of one request.
3
+ *
4
+ * A single page builds several `DataAccessor`s — the list itself plus one per association
5
+ * dropdown — and each would otherwise repeat the same membership and group lookups. The
6
+ * cache is deliberately short-lived: it is created per request (or per accessor, when the
7
+ * caller does not supply one) and never outlives it, because a stale access decision is far more
8
+ * dangerous than a repeated query.
9
+ *
10
+ * Every key carries the user id and the action verb, so sharing one instance between the
11
+ * accessors of a page can never hand one user's decision to another, nor a read decision to
12
+ * a write. What is stored is the promise rather than its value: concurrent callers coalesce
13
+ * onto a single lookup, and a rejection likewise stays cached for the rest of the request.
14
+ */
15
+ export class RecordAccessCache {
16
+ entries = new Map();
17
+ /**
18
+ * Runs `factory` once per key; every later caller awaits the promise of the first one.
19
+ * Keys are opaque strings owned by the resolvers, which prefix them by kind (`membership:`,
20
+ * `graph:`) to keep the namespaces of the two record-access paths apart.
21
+ */
22
+ resolve(key, factory) {
23
+ let pending = this.entries.get(key);
24
+ if (pending === undefined) {
25
+ pending = factory();
26
+ this.entries.set(key, pending);
27
+ }
28
+ return pending;
29
+ }
30
+ }