@intranefr/superbackend 1.5.0 → 1.5.2

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 (198) hide show
  1. package/.env.example +15 -0
  2. package/README.md +11 -0
  3. package/analysis-only.skill +0 -0
  4. package/index.js +23 -0
  5. package/package.json +8 -2
  6. package/src/admin/endpointRegistry.js +120 -0
  7. package/src/controllers/admin.controller.js +90 -6
  8. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  9. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  10. package/src/controllers/adminCache.controller.js +342 -0
  11. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  12. package/src/controllers/adminCrons.controller.js +388 -0
  13. package/src/controllers/adminDbBrowser.controller.js +124 -0
  14. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  15. package/src/controllers/adminExperiments.controller.js +200 -0
  16. package/src/controllers/adminHeadless.controller.js +9 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +126 -4
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/blogAdmin.controller.js +279 -0
  28. package/src/controllers/blogAiAdmin.controller.js +224 -0
  29. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  30. package/src/controllers/blogInternal.controller.js +26 -0
  31. package/src/controllers/blogPublic.controller.js +89 -0
  32. package/src/controllers/experiments.controller.js +85 -0
  33. package/src/controllers/fileManager.controller.js +190 -0
  34. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  35. package/src/controllers/healthChecksPublic.controller.js +196 -0
  36. package/src/controllers/internalExperiments.controller.js +17 -0
  37. package/src/controllers/metrics.controller.js +64 -4
  38. package/src/controllers/orgAdmin.controller.js +80 -0
  39. package/src/helpers/mongooseHelper.js +258 -0
  40. package/src/helpers/scriptBase.js +230 -0
  41. package/src/helpers/scriptRunner.js +335 -0
  42. package/src/middleware/rbac.js +62 -0
  43. package/src/middleware.js +810 -48
  44. package/src/models/BlockDefinition.js +27 -0
  45. package/src/models/BlogAutomationLock.js +14 -0
  46. package/src/models/BlogAutomationRun.js +39 -0
  47. package/src/models/BlogPost.js +42 -0
  48. package/src/models/CacheEntry.js +26 -0
  49. package/src/models/ConsoleEntry.js +32 -0
  50. package/src/models/ConsoleLog.js +23 -0
  51. package/src/models/ContextBlockDefinition.js +33 -0
  52. package/src/models/CronExecution.js +47 -0
  53. package/src/models/CronJob.js +70 -0
  54. package/src/models/Experiment.js +75 -0
  55. package/src/models/ExperimentAssignment.js +23 -0
  56. package/src/models/ExperimentEvent.js +26 -0
  57. package/src/models/ExperimentMetricBucket.js +30 -0
  58. package/src/models/ExternalDbConnection.js +49 -0
  59. package/src/models/FileEntry.js +22 -0
  60. package/src/models/GlobalSetting.js +1 -2
  61. package/src/models/HealthAutoHealAttempt.js +57 -0
  62. package/src/models/HealthCheck.js +132 -0
  63. package/src/models/HealthCheckRun.js +51 -0
  64. package/src/models/HealthIncident.js +49 -0
  65. package/src/models/Page.js +95 -0
  66. package/src/models/PageCollection.js +42 -0
  67. package/src/models/ProxyEntry.js +66 -0
  68. package/src/models/RateLimitCounter.js +19 -0
  69. package/src/models/RateLimitMetricBucket.js +20 -0
  70. package/src/models/RbacGrant.js +25 -0
  71. package/src/models/RbacGroup.js +16 -0
  72. package/src/models/RbacGroupMember.js +13 -0
  73. package/src/models/RbacGroupRole.js +13 -0
  74. package/src/models/RbacRole.js +25 -0
  75. package/src/models/RbacUserRole.js +13 -0
  76. package/src/models/ScriptDefinition.js +1 -0
  77. package/src/models/Webhook.js +2 -0
  78. package/src/routes/admin.routes.js +2 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminExperiments.routes.js +29 -0
  88. package/src/routes/adminHeadless.routes.js +2 -1
  89. package/src/routes/adminHealthChecks.routes.js +28 -0
  90. package/src/routes/adminI18n.routes.js +4 -3
  91. package/src/routes/adminLlm.routes.js +4 -2
  92. package/src/routes/adminPages.routes.js +55 -0
  93. package/src/routes/adminProxy.routes.js +15 -0
  94. package/src/routes/adminRateLimits.routes.js +17 -0
  95. package/src/routes/adminRbac.routes.js +38 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminUiComponents.routes.js +2 -1
  98. package/src/routes/blogInternal.routes.js +14 -0
  99. package/src/routes/blogPublic.routes.js +9 -0
  100. package/src/routes/experiments.routes.js +30 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/internalExperiments.routes.js +15 -0
  105. package/src/routes/log.routes.js +43 -60
  106. package/src/routes/metrics.routes.js +4 -2
  107. package/src/routes/orgAdmin.routes.js +1 -0
  108. package/src/routes/pages.routes.js +123 -0
  109. package/src/routes/proxy.routes.js +46 -0
  110. package/src/routes/rbac.routes.js +47 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +185 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +738 -0
  120. package/src/services/consoleOverride.service.js +7 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/experiments.service.js +273 -0
  125. package/src/services/experimentsAggregation.service.js +308 -0
  126. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  127. package/src/services/experimentsRetention.service.js +43 -0
  128. package/src/services/experimentsWs.service.js +134 -0
  129. package/src/services/fileManager.service.js +475 -0
  130. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  131. package/src/services/globalSettings.service.js +15 -0
  132. package/src/services/healthChecks.service.js +650 -0
  133. package/src/services/healthChecksBootstrap.service.js +109 -0
  134. package/src/services/healthChecksScheduler.service.js +106 -0
  135. package/src/services/jsonConfigs.service.js +2 -2
  136. package/src/services/llmDefaults.service.js +190 -0
  137. package/src/services/migrationAssets/s3.js +2 -2
  138. package/src/services/pages.service.js +602 -0
  139. package/src/services/pagesContext.service.js +331 -0
  140. package/src/services/pagesContextBlocksAi.service.js +349 -0
  141. package/src/services/proxy.service.js +535 -0
  142. package/src/services/rateLimiter.service.js +623 -0
  143. package/src/services/rbac.service.js +212 -0
  144. package/src/services/scriptsRunner.service.js +215 -15
  145. package/src/services/uiComponentsAi.service.js +6 -19
  146. package/src/services/workflow.service.js +23 -8
  147. package/src/utils/orgRoles.js +14 -0
  148. package/src/utils/rbac/engine.js +60 -0
  149. package/src/utils/rbac/rightsRegistry.js +33 -0
  150. package/views/admin-blog-automation.ejs +877 -0
  151. package/views/admin-blog-edit.ejs +542 -0
  152. package/views/admin-blog.ejs +399 -0
  153. package/views/admin-cache.ejs +681 -0
  154. package/views/admin-console-manager.ejs +680 -0
  155. package/views/admin-crons.ejs +645 -0
  156. package/views/admin-dashboard.ejs +28 -8
  157. package/views/admin-db-browser.ejs +445 -0
  158. package/views/admin-ejs-virtual.ejs +16 -10
  159. package/views/admin-experiments.ejs +91 -0
  160. package/views/admin-file-manager.ejs +942 -0
  161. package/views/admin-health-checks.ejs +725 -0
  162. package/views/admin-i18n.ejs +59 -5
  163. package/views/admin-llm.ejs +99 -1
  164. package/views/admin-organizations.ejs +163 -1
  165. package/views/admin-pages.ejs +2424 -0
  166. package/views/admin-proxy.ejs +491 -0
  167. package/views/admin-rate-limiter.ejs +625 -0
  168. package/views/admin-rbac.ejs +1331 -0
  169. package/views/admin-scripts.ejs +597 -3
  170. package/views/admin-seo-config.ejs +61 -7
  171. package/views/admin-ui-components.ejs +57 -25
  172. package/views/admin-workflows.ejs +7 -7
  173. package/views/file-manager.ejs +866 -0
  174. package/views/pages/blocks/contact.ejs +27 -0
  175. package/views/pages/blocks/cta.ejs +18 -0
  176. package/views/pages/blocks/faq.ejs +20 -0
  177. package/views/pages/blocks/features.ejs +19 -0
  178. package/views/pages/blocks/hero.ejs +13 -0
  179. package/views/pages/blocks/html.ejs +5 -0
  180. package/views/pages/blocks/image.ejs +14 -0
  181. package/views/pages/blocks/testimonials.ejs +26 -0
  182. package/views/pages/blocks/text.ejs +10 -0
  183. package/views/pages/layouts/default.ejs +51 -0
  184. package/views/pages/layouts/minimal.ejs +42 -0
  185. package/views/pages/layouts/sidebar.ejs +54 -0
  186. package/views/pages/partials/footer.ejs +13 -0
  187. package/views/pages/partials/header.ejs +12 -0
  188. package/views/pages/partials/sidebar.ejs +8 -0
  189. package/views/pages/runtime/page.ejs +10 -0
  190. package/views/pages/templates/article.ejs +20 -0
  191. package/views/pages/templates/default.ejs +12 -0
  192. package/views/pages/templates/landing.ejs +14 -0
  193. package/views/pages/templates/listing.ejs +15 -0
  194. package/views/partials/admin-image-upload-modal.ejs +221 -0
  195. package/views/partials/dashboard/nav-items.ejs +12 -0
  196. package/views/partials/dashboard/palette.ejs +5 -3
  197. package/views/partials/llm-provider-model-picker.ejs +183 -0
  198. package/src/routes/llmUi.routes.js +0 -26
@@ -0,0 +1,602 @@
1
+ const Page = require('../models/Page');
2
+ const PageCollection = require('../models/PageCollection');
3
+ const BlockDefinition = require('../models/BlockDefinition');
4
+ const ejsVirtualService = require('./ejsVirtual.service');
5
+ const pagesContextService = require('./pagesContext.service');
6
+ const { getJsonConfigValueBySlug } = require('./jsonConfigs.service');
7
+
8
+ const RESERVED_SEGMENTS = new Set(['api', 'public', 'w', 'admin']);
9
+
10
+ const BLOCKS_SCHEMA_JSON_CONFIG_ALIAS = 'page-builder-blocks-schema';
11
+
12
+ function getDefaultBlocksSchema() {
13
+ return {
14
+ version: 1,
15
+ blocks: {
16
+ hero: {
17
+ label: 'Hero',
18
+ fields: {
19
+ title: { type: 'string', label: 'Title' },
20
+ subtitle: { type: 'string', label: 'Subtitle' },
21
+ ctaText: { type: 'string', label: 'CTA Text' },
22
+ ctaUrl: { type: 'string', label: 'CTA URL' },
23
+ },
24
+ },
25
+ text: {
26
+ label: 'Text',
27
+ fields: {
28
+ title: { type: 'string', label: 'Title' },
29
+ content: { type: 'html', label: 'Content (HTML)' },
30
+ },
31
+ },
32
+ image: {
33
+ label: 'Image',
34
+ fields: {
35
+ src: { type: 'string', label: 'Image URL' },
36
+ alt: { type: 'string', label: 'Alt Text' },
37
+ caption: { type: 'string', label: 'Caption' },
38
+ fullWidth: { type: 'boolean', label: 'Full Width' },
39
+ align: { type: 'select', label: 'Align', options: ['left', 'center', 'right'] },
40
+ },
41
+ },
42
+ cta: {
43
+ label: 'CTA',
44
+ fields: {
45
+ title: { type: 'string', label: 'Title' },
46
+ description: { type: 'string', label: 'Description' },
47
+ buttonText: { type: 'string', label: 'Button Text' },
48
+ buttonUrl: { type: 'string', label: 'Button URL' },
49
+ },
50
+ },
51
+ features: {
52
+ label: 'Features',
53
+ fields: {
54
+ title: { type: 'string', label: 'Title' },
55
+ items: { type: 'json', label: 'Items (JSON array)', example: [{ title: 'Fast setup', description: 'Get started in minutes', icon: 'bolt' }] },
56
+ },
57
+ },
58
+ testimonials: {
59
+ label: 'Testimonials',
60
+ fields: {
61
+ title: { type: 'string', label: 'Title' },
62
+ items: { type: 'json', label: 'Items (JSON array)', example: [{ quote: 'This product is amazing.', name: 'Jane Doe', role: 'CEO', avatar: '/public/avatar.png' }] },
63
+ },
64
+ },
65
+ faq: {
66
+ label: 'FAQ',
67
+ fields: {
68
+ title: { type: 'string', label: 'Title' },
69
+ items: { type: 'json', label: 'Items (JSON array)', example: [{ question: 'What is this?', answer: 'A page builder powered by blocks.' }] },
70
+ },
71
+ },
72
+ contact: {
73
+ label: 'Contact',
74
+ fields: {
75
+ title: { type: 'string', label: 'Title' },
76
+ action: { type: 'string', label: 'Form Action' },
77
+ formId: { type: 'string', label: 'Form ID' },
78
+ buttonText: { type: 'string', label: 'Button Text' },
79
+ },
80
+ },
81
+ html: {
82
+ label: 'HTML',
83
+ fields: {
84
+ html: { type: 'html', label: 'HTML' },
85
+ },
86
+ },
87
+ 'context.db_query': {
88
+ label: 'Context: DB Query',
89
+ fields: {
90
+ model: { type: 'string', label: 'Model' },
91
+ op: { type: 'select', label: 'Operation', options: ['find', 'findOne', 'countDocuments'] },
92
+ filter: { type: 'json', label: 'Filter (JSON)' },
93
+ sort: { type: 'json', label: 'Sort (JSON)' },
94
+ select: { type: 'json', label: 'Select (JSON)' },
95
+ limit: { type: 'number', label: 'Limit' },
96
+ assignTo: { type: 'string', label: 'Assign to vars key' },
97
+ cache: { type: 'json', label: 'Cache config (JSON)' },
98
+ timeout: { type: 'json', label: 'Timeout config (JSON)' },
99
+ },
100
+ },
101
+ 'context.service_invoke': {
102
+ label: 'Context: Service Invoke',
103
+ fields: {
104
+ servicePath: { type: 'string', label: 'Service path (helpers.*)' },
105
+ args: { type: 'json', label: 'Args (JSON)' },
106
+ assignTo: { type: 'string', label: 'Assign to vars key' },
107
+ cache: { type: 'json', label: 'Cache config (JSON)' },
108
+ timeout: { type: 'json', label: 'Timeout config (JSON)' },
109
+ },
110
+ },
111
+ },
112
+ };
113
+ }
114
+
115
+ function inferRepeatParams(page, { routePath, segments, collectionSlug }) {
116
+ const repeat = page && page.repeat && typeof page.repeat === 'object' ? page.repeat : null;
117
+ if (!repeat) return null;
118
+
119
+ const paramKey = String(repeat.paramKey || 'slug').trim() || 'slug';
120
+ // Today we support one-segment dynamic routes within a collection: /<collection>/<param>
121
+ // (collectionSlug may itself include slashes)
122
+ const value = segments && segments.length > 0 ? segments[segments.length - 1] : null;
123
+ if (!value) return null;
124
+
125
+ return {
126
+ [paramKey]: value,
127
+ collectionSlug: collectionSlug || null,
128
+ routePath: routePath || null,
129
+ };
130
+ }
131
+
132
+ function isRepeatEnabledForRoot(page) {
133
+ const repeat = page && page.repeat && typeof page.repeat === 'object' ? page.repeat : null;
134
+ return Boolean(repeat && repeat.allowRoot === true);
135
+ }
136
+
137
+ async function getBlocksSchema({ bypassCache = false } = {}) {
138
+ try {
139
+ let schema = await getJsonConfigValueBySlug(BLOCKS_SCHEMA_JSON_CONFIG_ALIAS, { bypassCache });
140
+ if (!schema || typeof schema !== 'object' || !schema.blocks || typeof schema.blocks !== 'object') {
141
+ schema = getDefaultBlocksSchema();
142
+ }
143
+
144
+ let defs = [];
145
+ try {
146
+ defs = await BlockDefinition.find({ isActive: true }).sort({ updatedAt: -1 }).lean();
147
+ } catch (_) {
148
+ defs = [];
149
+ }
150
+
151
+ if (!defs.length) return schema;
152
+
153
+ const mergedBlocks = { ...(schema.blocks || {}) };
154
+ for (const d of defs) {
155
+ const code = String(d.code || '').trim();
156
+ if (!code) continue;
157
+ mergedBlocks[code] = {
158
+ label: String(d.label || code),
159
+ description: String(d.description || ''),
160
+ fields: (d.fields && typeof d.fields === 'object' && !Array.isArray(d.fields)) ? d.fields : {},
161
+ version: Number(d.version || 1) || 1,
162
+ source: 'db',
163
+ };
164
+ }
165
+
166
+ return { ...schema, blocks: mergedBlocks };
167
+ } catch (err) {
168
+ if (err && err.code === 'NOT_FOUND') {
169
+ const base = getDefaultBlocksSchema();
170
+ let defs = [];
171
+ try {
172
+ defs = await BlockDefinition.find({ isActive: true }).sort({ updatedAt: -1 }).lean();
173
+ } catch (_) {
174
+ defs = [];
175
+ }
176
+
177
+ if (!defs.length) return base;
178
+
179
+ const mergedBlocks = { ...(base.blocks || {}) };
180
+ for (const d of defs) {
181
+ const code = String(d.code || '').trim();
182
+ if (!code) continue;
183
+ mergedBlocks[code] = {
184
+ label: String(d.label || code),
185
+ description: String(d.description || ''),
186
+ fields: (d.fields && typeof d.fields === 'object' && !Array.isArray(d.fields)) ? d.fields : {},
187
+ version: Number(d.version || 1) || 1,
188
+ source: 'db',
189
+ };
190
+ }
191
+
192
+ return { ...base, blocks: mergedBlocks };
193
+ }
194
+ throw err;
195
+ }
196
+ }
197
+
198
+ function validateBlocks(blocks, schema) {
199
+ if (blocks === undefined) return;
200
+ if (!Array.isArray(blocks)) {
201
+ const err = new Error('blocks must be an array');
202
+ err.code = 'VALIDATION';
203
+ throw err;
204
+ }
205
+
206
+ const blockDefs = schema?.blocks || {};
207
+
208
+ for (const block of blocks) {
209
+ if (!block || typeof block !== 'object') {
210
+ const err = new Error('Each block must be an object');
211
+ err.code = 'VALIDATION';
212
+ throw err;
213
+ }
214
+
215
+ const id = String(block.id || '').trim();
216
+ if (!id) {
217
+ const err = new Error('Each block must have an id');
218
+ err.code = 'VALIDATION';
219
+ throw err;
220
+ }
221
+
222
+ const type = String(block.type || '').trim();
223
+ if (!type) {
224
+ const err = new Error('Each block must have a type');
225
+ err.code = 'VALIDATION';
226
+ throw err;
227
+ }
228
+
229
+ const def = blockDefs[type];
230
+ if (!def) {
231
+ const err = new Error(`Unknown block type: ${type}`);
232
+ err.code = 'VALIDATION';
233
+ throw err;
234
+ }
235
+
236
+ if (block.props !== undefined && (block.props === null || typeof block.props !== 'object' || Array.isArray(block.props))) {
237
+ const err = new Error(`Block props must be an object for type: ${type}`);
238
+ err.code = 'VALIDATION';
239
+ throw err;
240
+ }
241
+
242
+ const props = block.props || {};
243
+ const fields = def?.fields && typeof def.fields === 'object' ? def.fields : {};
244
+ for (const key of Object.keys(fields)) {
245
+ if (!Object.prototype.hasOwnProperty.call(props, key)) continue;
246
+ const field = fields[key] || {};
247
+ const fieldType = String(field.type || '').toLowerCase();
248
+ const val = props[key];
249
+
250
+ if (val === null || val === undefined) continue;
251
+
252
+ if (fieldType === 'string' || fieldType === 'html') {
253
+ if (typeof val !== 'string') {
254
+ const err = new Error(`Block props.${key} must be a string for type: ${type}`);
255
+ err.code = 'VALIDATION';
256
+ throw err;
257
+ }
258
+ } else if (fieldType === 'boolean') {
259
+ if (typeof val !== 'boolean') {
260
+ const err = new Error(`Block props.${key} must be a boolean for type: ${type}`);
261
+ err.code = 'VALIDATION';
262
+ throw err;
263
+ }
264
+ } else if (fieldType === 'number') {
265
+ if (typeof val !== 'number' || Number.isNaN(val)) {
266
+ const err = new Error(`Block props.${key} must be a number for type: ${type}`);
267
+ err.code = 'VALIDATION';
268
+ throw err;
269
+ }
270
+ } else if (fieldType === 'select') {
271
+ const options = Array.isArray(field.options) ? field.options : [];
272
+ if (typeof val !== 'string') {
273
+ const err = new Error(`Block props.${key} must be a string for type: ${type}`);
274
+ err.code = 'VALIDATION';
275
+ throw err;
276
+ }
277
+ if (options.length > 0 && !options.includes(val)) {
278
+ const err = new Error(`Block props.${key} must be one of: ${options.join(', ')} for type: ${type}`);
279
+ err.code = 'VALIDATION';
280
+ throw err;
281
+ }
282
+ } else if (fieldType === 'json') {
283
+ const ok = typeof val === 'object' || typeof val === 'string';
284
+ if (!ok) {
285
+ const err = new Error(`Block props.${key} must be an object/array or JSON string for type: ${type}`);
286
+ err.code = 'VALIDATION';
287
+ throw err;
288
+ }
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ function computeReservedSegments(adminPath) {
295
+ const segments = new Set(RESERVED_SEGMENTS);
296
+ if (adminPath) {
297
+ const firstSegment = String(adminPath).replace(/^\//, '').split('/')[0];
298
+ if (firstSegment) {
299
+ segments.add(firstSegment.toLowerCase());
300
+ }
301
+ }
302
+ return segments;
303
+ }
304
+
305
+ function isReservedSegment(segment, adminPath) {
306
+ const reserved = computeReservedSegments(adminPath);
307
+ return reserved.has(String(segment).toLowerCase());
308
+ }
309
+
310
+ function validateSlug(slug) {
311
+ const s = String(slug || '').trim();
312
+ if (!s) {
313
+ const err = new Error('Slug is required');
314
+ err.code = 'VALIDATION';
315
+ throw err;
316
+ }
317
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s)) {
318
+ const err = new Error('Slug must be lowercase alphanumeric with hyphens only');
319
+ err.code = 'VALIDATION';
320
+ throw err;
321
+ }
322
+ return s;
323
+ }
324
+
325
+ function validateCollectionSlug(slug, pagesPrefix, adminPath) {
326
+ const s = validateSlug(slug);
327
+ if (pagesPrefix === '/' && isReservedSegment(s, adminPath)) {
328
+ const err = new Error(`Slug "${s}" is reserved and cannot be used as a collection slug when pagesPrefix is "/""`);
329
+ err.code = 'VALIDATION';
330
+ throw err;
331
+ }
332
+ return s;
333
+ }
334
+
335
+ function validatePageSlug(slug, collectionSlug, pagesPrefix, adminPath) {
336
+ const s = validateSlug(slug);
337
+ if (pagesPrefix === '/' && !collectionSlug && isReservedSegment(s, adminPath)) {
338
+ const err = new Error(`Slug "${s}" is reserved and cannot be used at root level when pagesPrefix is "/"`);
339
+ err.code = 'VALIDATION';
340
+ throw err;
341
+ }
342
+ return s;
343
+ }
344
+
345
+ function buildRoutePath(pagesPrefix, collectionSlug, pageSlug) {
346
+ const parts = [];
347
+ const prefix = String(pagesPrefix || '/').replace(/\/+$/, '');
348
+ if (prefix && prefix !== '/') {
349
+ parts.push(prefix.replace(/^\//, ''));
350
+ }
351
+ if (collectionSlug) {
352
+ parts.push(collectionSlug);
353
+ }
354
+ parts.push(pageSlug);
355
+ return '/' + parts.join('/');
356
+ }
357
+
358
+ async function findPageByRoutePath(routePath, options = {}) {
359
+ const { pagesPrefix = '/', tenantId = null, includeGlobal = true, statuses = ['published'] } = options;
360
+
361
+ const pathWithoutPrefix = pagesPrefix === '/'
362
+ ? routePath
363
+ : routePath.replace(new RegExp(`^${pagesPrefix}`), '');
364
+
365
+ const segments = pathWithoutPrefix.replace(/^\//, '').split('/').filter(Boolean);
366
+
367
+ if (segments.length === 0) {
368
+ return null;
369
+ }
370
+
371
+ const tenantQuery = [];
372
+ if (includeGlobal) {
373
+ tenantQuery.push({ isGlobal: true });
374
+ }
375
+ if (tenantId) {
376
+ tenantQuery.push({ tenantId, isGlobal: false });
377
+ }
378
+
379
+ if (tenantQuery.length === 0) {
380
+ return null;
381
+ }
382
+
383
+ if (segments.length === 1) {
384
+ const page = await Page.findOne({
385
+ slug: segments[0],
386
+ collectionId: null,
387
+ status: { $in: statuses },
388
+ $or: tenantQuery,
389
+ }).lean();
390
+
391
+ if (page) {
392
+ page._routePath = buildRoutePath(pagesPrefix, null, page.slug);
393
+ page._params = {};
394
+ }
395
+
396
+ if (page) return page;
397
+
398
+ // Repeat fallback for root-level pages is disabled by default.
399
+ // To enable, explicitly set page.repeat.allowRoot=true.
400
+ const repeatCandidate = await Page.findOne({
401
+ collectionId: null,
402
+ slug: '_',
403
+ status: { $in: statuses },
404
+ repeat: { $ne: null },
405
+ $or: tenantQuery,
406
+ }).sort({ updatedAt: -1 }).lean();
407
+
408
+ if (!repeatCandidate || !isRepeatEnabledForRoot(repeatCandidate)) return null;
409
+
410
+ repeatCandidate._routePath = buildRoutePath(pagesPrefix, null, segments[0]);
411
+ repeatCandidate._params = inferRepeatParams(repeatCandidate, { routePath, segments, collectionSlug: null }) || {};
412
+ repeatCandidate._repeatResolved = true;
413
+ return repeatCandidate;
414
+ }
415
+
416
+ const collectionSlug = segments.slice(0, -1).join('/');
417
+ const pageSlug = segments[segments.length - 1];
418
+
419
+ const collection = await PageCollection.findOne({
420
+ slug: collectionSlug,
421
+ status: 'active',
422
+ $or: tenantQuery,
423
+ }).lean();
424
+
425
+ if (!collection) {
426
+ return null;
427
+ }
428
+
429
+ const page = await Page.findOne({
430
+ slug: pageSlug,
431
+ collectionId: collection._id,
432
+ status: { $in: statuses },
433
+ $or: tenantQuery,
434
+ }).lean();
435
+
436
+ if (page) {
437
+ page._routePath = buildRoutePath(pagesPrefix, collectionSlug, page.slug);
438
+ page._collection = collection;
439
+ page._params = {};
440
+ return page;
441
+ }
442
+
443
+ // Repeat fallback within the collection.
444
+ const repeatPage = await Page.findOne({
445
+ collectionId: collection._id,
446
+ slug: '_',
447
+ status: { $in: statuses },
448
+ repeat: { $ne: null },
449
+ $or: tenantQuery,
450
+ }).sort({ updatedAt: -1 }).lean();
451
+
452
+ if (!repeatPage) {
453
+ // Backward-compatible fallback: any repeat page in the collection.
454
+ const anyRepeat = await Page.findOne({
455
+ collectionId: collection._id,
456
+ status: { $in: statuses },
457
+ repeat: { $ne: null },
458
+ $or: tenantQuery,
459
+ }).sort({ updatedAt: -1 }).lean();
460
+
461
+ if (!anyRepeat) return null;
462
+
463
+ anyRepeat._routePath = buildRoutePath(pagesPrefix, collectionSlug, pageSlug);
464
+ anyRepeat._collection = collection;
465
+ anyRepeat._params = inferRepeatParams(anyRepeat, { routePath, segments, collectionSlug }) || {};
466
+ anyRepeat._repeatResolved = true;
467
+ return anyRepeat;
468
+ }
469
+
470
+ // Route path should reflect the requested route, not the repeat page slug.
471
+ repeatPage._routePath = buildRoutePath(pagesPrefix, collectionSlug, pageSlug);
472
+ repeatPage._collection = collection;
473
+ repeatPage._params = inferRepeatParams(repeatPage, { routePath, segments, collectionSlug }) || {};
474
+ repeatPage._repeatResolved = true;
475
+ return repeatPage;
476
+ }
477
+
478
+ async function renderPage(page, options = {}) {
479
+ const { viewsRoot, req, res } = options;
480
+
481
+ const layoutKey = page.layoutKey || 'default';
482
+ const templateKey = page.templateKey || 'default';
483
+
484
+ const layoutPath = `pages/layouts/${layoutKey}.ejs`;
485
+ const templatePath = `pages/templates/${templateKey}.ejs`;
486
+
487
+ const routePath = (req && req.path) ? req.path : (page && page._routePath ? page._routePath : null);
488
+ const params = (page && page._params && typeof page._params === 'object') ? page._params : {};
489
+
490
+ const { pageContext, renderBlocks } = await pagesContextService.resolvePageContext({
491
+ page,
492
+ req,
493
+ res,
494
+ routePath,
495
+ params,
496
+ });
497
+
498
+ const data = {
499
+ page,
500
+ blocks: renderBlocks || [],
501
+ pageContext,
502
+ seoMeta: page.seoMeta || {},
503
+ customCss: page.customCss || '',
504
+ customJs: page.customJs || '',
505
+ layoutPath,
506
+ templatePath,
507
+ req,
508
+ };
509
+
510
+ const entryPath = 'pages/runtime/page.ejs';
511
+
512
+ const html = await ejsVirtualService.renderToString(res, entryPath, data, { viewsRoot });
513
+ return html;
514
+ }
515
+
516
+ async function listPages(query = {}) {
517
+ const { tenantId, collectionId, status, isGlobal, limit = 50, offset = 0, search } = query;
518
+
519
+ const filter = {};
520
+
521
+ if (tenantId !== undefined) {
522
+ filter.tenantId = tenantId;
523
+ }
524
+ if (collectionId !== undefined) {
525
+ filter.collectionId = collectionId;
526
+ }
527
+ if (status) {
528
+ filter.status = status;
529
+ }
530
+ if (isGlobal !== undefined) {
531
+ filter.isGlobal = isGlobal;
532
+ }
533
+ if (search) {
534
+ filter.$or = [
535
+ { title: { $regex: search, $options: 'i' } },
536
+ { slug: { $regex: search, $options: 'i' } },
537
+ ];
538
+ }
539
+
540
+ const [pages, total] = await Promise.all([
541
+ Page.find(filter)
542
+ .populate('collectionId', 'slug name')
543
+ .sort({ updatedAt: -1 })
544
+ .skip(offset)
545
+ .limit(limit)
546
+ .lean(),
547
+ Page.countDocuments(filter),
548
+ ]);
549
+
550
+ return { pages, total, limit, offset };
551
+ }
552
+
553
+ async function listCollections(query = {}) {
554
+ const { tenantId, status, isGlobal, limit = 50, offset = 0, search } = query;
555
+
556
+ const filter = {};
557
+
558
+ if (tenantId !== undefined) {
559
+ filter.tenantId = tenantId;
560
+ }
561
+ if (status) {
562
+ filter.status = status;
563
+ }
564
+ if (isGlobal !== undefined) {
565
+ filter.isGlobal = isGlobal;
566
+ }
567
+ if (search) {
568
+ filter.$or = [
569
+ { name: { $regex: search, $options: 'i' } },
570
+ { slug: { $regex: search, $options: 'i' } },
571
+ ];
572
+ }
573
+
574
+ const [collections, total] = await Promise.all([
575
+ PageCollection.find(filter)
576
+ .sort({ updatedAt: -1 })
577
+ .skip(offset)
578
+ .limit(limit)
579
+ .lean(),
580
+ PageCollection.countDocuments(filter),
581
+ ]);
582
+
583
+ return { collections, total, limit, offset };
584
+ }
585
+
586
+ module.exports = {
587
+ RESERVED_SEGMENTS,
588
+ BLOCKS_SCHEMA_JSON_CONFIG_ALIAS,
589
+ getDefaultBlocksSchema,
590
+ getBlocksSchema,
591
+ validateBlocks,
592
+ computeReservedSegments,
593
+ isReservedSegment,
594
+ validateSlug,
595
+ validateCollectionSlug,
596
+ validatePageSlug,
597
+ buildRoutePath,
598
+ findPageByRoutePath,
599
+ renderPage,
600
+ listPages,
601
+ listCollections,
602
+ };