@intranefr/superbackend 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/.env.example +5 -0
  2. package/README.md +11 -0
  3. package/index.js +39 -1
  4. package/package.json +11 -3
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/ui-components/browser/src/index.js +228 -0
  7. package/src/admin/endpointRegistry.js +120 -0
  8. package/src/controllers/admin.controller.js +111 -5
  9. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  10. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  11. package/src/controllers/adminCache.controller.js +342 -0
  12. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  13. package/src/controllers/adminCrons.controller.js +388 -0
  14. package/src/controllers/adminDbBrowser.controller.js +124 -0
  15. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  16. package/src/controllers/adminHeadless.controller.js +91 -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 +320 -0
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/adminTerminals.controller.js +39 -0
  28. package/src/controllers/adminUiComponents.controller.js +315 -0
  29. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  30. package/src/controllers/blogAdmin.controller.js +279 -0
  31. package/src/controllers/blogAiAdmin.controller.js +224 -0
  32. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  33. package/src/controllers/blogInternal.controller.js +26 -0
  34. package/src/controllers/blogPublic.controller.js +89 -0
  35. package/src/controllers/fileManager.controller.js +190 -0
  36. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  37. package/src/controllers/healthChecksPublic.controller.js +196 -0
  38. package/src/controllers/metrics.controller.js +64 -4
  39. package/src/controllers/orgAdmin.controller.js +366 -0
  40. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  41. package/src/middleware/auth.js +7 -0
  42. package/src/middleware/internalCronAuth.js +29 -0
  43. package/src/middleware/rbac.js +62 -0
  44. package/src/middleware.js +879 -56
  45. package/src/models/BlockDefinition.js +27 -0
  46. package/src/models/BlogAutomationLock.js +14 -0
  47. package/src/models/BlogAutomationRun.js +39 -0
  48. package/src/models/BlogPost.js +42 -0
  49. package/src/models/CacheEntry.js +26 -0
  50. package/src/models/ConsoleEntry.js +32 -0
  51. package/src/models/ConsoleLog.js +23 -0
  52. package/src/models/ContextBlockDefinition.js +33 -0
  53. package/src/models/CronExecution.js +47 -0
  54. package/src/models/CronJob.js +70 -0
  55. package/src/models/ExternalDbConnection.js +49 -0
  56. package/src/models/FileEntry.js +22 -0
  57. package/src/models/HeadlessModelDefinition.js +10 -0
  58. package/src/models/HealthAutoHealAttempt.js +57 -0
  59. package/src/models/HealthCheck.js +132 -0
  60. package/src/models/HealthCheckRun.js +51 -0
  61. package/src/models/HealthIncident.js +49 -0
  62. package/src/models/Page.js +95 -0
  63. package/src/models/PageCollection.js +42 -0
  64. package/src/models/ProxyEntry.js +66 -0
  65. package/src/models/RateLimitCounter.js +19 -0
  66. package/src/models/RateLimitMetricBucket.js +20 -0
  67. package/src/models/RbacGrant.js +25 -0
  68. package/src/models/RbacGroup.js +16 -0
  69. package/src/models/RbacGroupMember.js +13 -0
  70. package/src/models/RbacGroupRole.js +13 -0
  71. package/src/models/RbacRole.js +25 -0
  72. package/src/models/RbacUserRole.js +13 -0
  73. package/src/models/ScriptDefinition.js +42 -0
  74. package/src/models/ScriptRun.js +22 -0
  75. package/src/models/UiComponent.js +29 -0
  76. package/src/models/UiComponentProject.js +26 -0
  77. package/src/models/UiComponentProjectComponent.js +18 -0
  78. package/src/routes/admin.routes.js +1 -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/adminHeadless.routes.js +8 -1
  88. package/src/routes/adminHealthChecks.routes.js +28 -0
  89. package/src/routes/adminI18n.routes.js +4 -3
  90. package/src/routes/adminLlm.routes.js +4 -2
  91. package/src/routes/adminPages.routes.js +55 -0
  92. package/src/routes/adminProxy.routes.js +15 -0
  93. package/src/routes/adminRateLimits.routes.js +17 -0
  94. package/src/routes/adminRbac.routes.js +38 -0
  95. package/src/routes/adminScripts.routes.js +21 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminTerminals.routes.js +13 -0
  98. package/src/routes/adminUiComponents.routes.js +30 -0
  99. package/src/routes/blogInternal.routes.js +14 -0
  100. package/src/routes/blogPublic.routes.js +9 -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/log.routes.js +43 -60
  105. package/src/routes/metrics.routes.js +4 -2
  106. package/src/routes/orgAdmin.routes.js +6 -0
  107. package/src/routes/pages.routes.js +123 -0
  108. package/src/routes/proxy.routes.js +46 -0
  109. package/src/routes/rbac.routes.js +47 -0
  110. package/src/routes/uiComponentsPublic.routes.js +9 -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 +184 -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 +700 -0
  120. package/src/services/consoleOverride.service.js +6 -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/fileManager.service.js +475 -0
  125. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  126. package/src/services/headlessExternalModels.service.js +292 -0
  127. package/src/services/headlessModels.service.js +26 -6
  128. package/src/services/healthChecks.service.js +650 -0
  129. package/src/services/healthChecksBootstrap.service.js +109 -0
  130. package/src/services/healthChecksScheduler.service.js +106 -0
  131. package/src/services/llmDefaults.service.js +190 -0
  132. package/src/services/migrationAssets/s3.js +2 -2
  133. package/src/services/pages.service.js +602 -0
  134. package/src/services/pagesContext.service.js +331 -0
  135. package/src/services/pagesContextBlocksAi.service.js +349 -0
  136. package/src/services/proxy.service.js +535 -0
  137. package/src/services/rateLimiter.service.js +623 -0
  138. package/src/services/rbac.service.js +212 -0
  139. package/src/services/scriptsRunner.service.js +259 -0
  140. package/src/services/terminals.service.js +152 -0
  141. package/src/services/terminalsWs.service.js +100 -0
  142. package/src/services/uiComponentsAi.service.js +299 -0
  143. package/src/services/uiComponentsCrypto.service.js +39 -0
  144. package/src/services/workflow.service.js +23 -8
  145. package/src/utils/orgRoles.js +14 -0
  146. package/src/utils/rbac/engine.js +60 -0
  147. package/src/utils/rbac/rightsRegistry.js +29 -0
  148. package/views/admin-blog-automation.ejs +877 -0
  149. package/views/admin-blog-edit.ejs +542 -0
  150. package/views/admin-blog.ejs +399 -0
  151. package/views/admin-cache.ejs +681 -0
  152. package/views/admin-console-manager.ejs +680 -0
  153. package/views/admin-crons.ejs +645 -0
  154. package/views/admin-db-browser.ejs +445 -0
  155. package/views/admin-ejs-virtual.ejs +16 -10
  156. package/views/admin-file-manager.ejs +942 -0
  157. package/views/admin-headless.ejs +294 -24
  158. package/views/admin-health-checks.ejs +725 -0
  159. package/views/admin-i18n.ejs +59 -5
  160. package/views/admin-llm.ejs +99 -1
  161. package/views/admin-organizations.ejs +528 -10
  162. package/views/admin-pages.ejs +2424 -0
  163. package/views/admin-proxy.ejs +491 -0
  164. package/views/admin-rate-limiter.ejs +625 -0
  165. package/views/admin-rbac.ejs +1331 -0
  166. package/views/admin-scripts.ejs +497 -0
  167. package/views/admin-seo-config.ejs +61 -7
  168. package/views/admin-terminals.ejs +328 -0
  169. package/views/admin-ui-components.ejs +741 -0
  170. package/views/admin-users.ejs +261 -4
  171. package/views/admin-workflows.ejs +7 -7
  172. package/views/file-manager.ejs +866 -0
  173. package/views/pages/blocks/contact.ejs +27 -0
  174. package/views/pages/blocks/cta.ejs +18 -0
  175. package/views/pages/blocks/faq.ejs +20 -0
  176. package/views/pages/blocks/features.ejs +19 -0
  177. package/views/pages/blocks/hero.ejs +13 -0
  178. package/views/pages/blocks/html.ejs +5 -0
  179. package/views/pages/blocks/image.ejs +14 -0
  180. package/views/pages/blocks/testimonials.ejs +26 -0
  181. package/views/pages/blocks/text.ejs +10 -0
  182. package/views/pages/layouts/default.ejs +51 -0
  183. package/views/pages/layouts/minimal.ejs +42 -0
  184. package/views/pages/layouts/sidebar.ejs +54 -0
  185. package/views/pages/partials/footer.ejs +13 -0
  186. package/views/pages/partials/header.ejs +12 -0
  187. package/views/pages/partials/sidebar.ejs +8 -0
  188. package/views/pages/runtime/page.ejs +10 -0
  189. package/views/pages/templates/article.ejs +20 -0
  190. package/views/pages/templates/default.ejs +12 -0
  191. package/views/pages/templates/landing.ejs +14 -0
  192. package/views/pages/templates/listing.ejs +15 -0
  193. package/views/partials/admin-image-upload-modal.ejs +221 -0
  194. package/views/partials/dashboard/nav-items.ejs +14 -0
  195. package/views/partials/llm-provider-model-picker.ejs +183 -0
@@ -0,0 +1,331 @@
1
+ const crypto = require('crypto');
2
+ const mongoose = require('mongoose');
3
+
4
+ const cacheLayer = require('./cacheLayer.service');
5
+ const globalSettingsService = require('./globalSettings.service');
6
+
7
+ function sha1(text) {
8
+ return crypto.createHash('sha1').update(String(text || ''), 'utf8').digest('hex');
9
+ }
10
+
11
+ function parseDurationToMs(input) {
12
+ if (input === null || input === undefined) return null;
13
+ if (typeof input === 'number' && Number.isFinite(input)) return input;
14
+
15
+ const s = String(input).trim().toLowerCase();
16
+ if (!s) return null;
17
+
18
+ const m = s.match(/^([0-9]+(?:\.[0-9]+)?)\s*(ms|s|m)$/);
19
+ if (!m) return null;
20
+
21
+ const n = Number(m[1]);
22
+ const unit = m[2];
23
+ if (!Number.isFinite(n)) return null;
24
+
25
+ if (unit === 'ms') return Math.round(n);
26
+ if (unit === 's') return Math.round(n * 1000);
27
+ if (unit === 'm') return Math.round(n * 60 * 1000);
28
+ return null;
29
+ }
30
+
31
+ async function getDefaultTimeoutMs() {
32
+ const fromSetting = await globalSettingsService.getSettingValue('PAGES_CONTEXT_BLOCK_TIMEOUT', null).catch(() => null);
33
+ const fromEnv = process.env.PAGES_CONTEXT_BLOCK_TIMEOUT;
34
+ const v = fromSetting !== null && fromSetting !== undefined ? fromSetting : (fromEnv !== undefined ? fromEnv : '30s');
35
+ const ms = parseDurationToMs(v);
36
+ return ms === null ? 30_000 : ms;
37
+ }
38
+
39
+ function getByPath(obj, path) {
40
+ const parts = String(path || '').split('.').filter(Boolean);
41
+ let cur = obj;
42
+ for (const p of parts) {
43
+ if (!cur || typeof cur !== 'object') return undefined;
44
+ cur = cur[p];
45
+ }
46
+ return cur;
47
+ }
48
+
49
+ function interpolateCtx(value, ctxRoot) {
50
+ if (value === null || value === undefined) return value;
51
+
52
+ if (Array.isArray(value)) {
53
+ return value.map((v) => interpolateCtx(v, ctxRoot));
54
+ }
55
+
56
+ if (typeof value === 'object') {
57
+ const keys = Object.keys(value);
58
+ if (keys.length === 1 && keys[0] === '$ctx') {
59
+ return getByPath(ctxRoot, value.$ctx);
60
+ }
61
+
62
+ const out = {};
63
+ for (const k of keys) {
64
+ out[k] = interpolateCtx(value[k], ctxRoot);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ return value;
70
+ }
71
+
72
+ function buildHelpers() {
73
+ const sb = globalThis.superbackend || globalThis.saasbackend || null;
74
+ const services = (sb && sb.services) ? sb.services : {};
75
+ const models = (sb && sb.models) ? sb.models : {};
76
+
77
+ const denyServices = new Set([
78
+ 'globalSettings',
79
+ 'migration',
80
+ 'workflow',
81
+ ]);
82
+
83
+ const safeServices = {};
84
+ for (const [k, v] of Object.entries(services || {})) {
85
+ if (denyServices.has(k)) continue;
86
+ safeServices[k] = v;
87
+ }
88
+
89
+ return {
90
+ services: safeServices,
91
+ models,
92
+ mongoose,
93
+ };
94
+ }
95
+
96
+ function buildAuthContext(req) {
97
+ const user = req?.user ? req.user : null;
98
+ if (!user) return null;
99
+ return {
100
+ userId: user._id ? String(user._id) : null,
101
+ role: user.role || null,
102
+ };
103
+ }
104
+
105
+ function buildSessionContext(req) {
106
+ const session = req?.session || null;
107
+ if (!session || typeof session !== 'object') return null;
108
+
109
+ const safe = {};
110
+ for (const [k, v] of Object.entries(session)) {
111
+ if (k.toLowerCase().includes('token')) continue;
112
+ if (k.toLowerCase().includes('secret')) continue;
113
+ safe[k] = v;
114
+ }
115
+ return safe;
116
+ }
117
+
118
+ async function withOptionalTimeout(promise, { enabled, timeoutMs }) {
119
+ if (!enabled) return promise;
120
+
121
+ const ms = Number(timeoutMs);
122
+ if (!Number.isFinite(ms) || ms <= 0) return promise;
123
+
124
+ let t;
125
+ const timeoutPromise = new Promise((_, reject) => {
126
+ t = setTimeout(() => {
127
+ const err = new Error('Context block timed out');
128
+ err.code = 'TIMEOUT';
129
+ reject(err);
130
+ }, ms);
131
+ });
132
+
133
+ try {
134
+ return await Promise.race([promise, timeoutPromise]);
135
+ } finally {
136
+ if (t) clearTimeout(t);
137
+ }
138
+ }
139
+
140
+ async function runDbQueryBlock(block, pageContext) {
141
+ const props = block?.props || {};
142
+
143
+ const modelName = String(props.model || '').trim();
144
+ if (!modelName) throw Object.assign(new Error('db.query requires props.model'), { code: 'VALIDATION' });
145
+
146
+ const op = String(props.op || (props.mode === 'one' ? 'findOne' : 'find')).trim();
147
+ const assignTo = String(props.assignTo || '').trim();
148
+ if (!assignTo) throw Object.assign(new Error('db.query requires props.assignTo'), { code: 'VALIDATION' });
149
+
150
+ const Model = mongoose.models[modelName] || (mongoose.modelNames().includes(modelName) ? mongoose.model(modelName) : null);
151
+ if (!Model) throw Object.assign(new Error(`Unknown model: ${modelName}`), { code: 'VALIDATION' });
152
+
153
+ const ctxRoot = {
154
+ pageContext,
155
+ auth: pageContext.auth,
156
+ session: pageContext.session,
157
+ vars: pageContext.vars,
158
+ params: pageContext.params,
159
+ query: pageContext.query,
160
+ };
161
+
162
+ const filter = interpolateCtx(props.filter || {}, ctxRoot);
163
+ const sort = interpolateCtx(props.sort || undefined, ctxRoot);
164
+ const select = interpolateCtx(props.select || undefined, ctxRoot);
165
+ const limit = interpolateCtx(props.limit || undefined, ctxRoot);
166
+
167
+ let q;
168
+ if (op === 'findOne') {
169
+ q = Model.findOne(filter);
170
+ } else if (op === 'find') {
171
+ q = Model.find(filter);
172
+ } else if (op === 'countDocuments') {
173
+ q = Model.countDocuments(filter);
174
+ } else {
175
+ throw Object.assign(new Error(`Unsupported db.query op: ${op}`), { code: 'VALIDATION' });
176
+ }
177
+
178
+ if (select !== undefined && select !== null && typeof q.select === 'function') {
179
+ q = q.select(select);
180
+ }
181
+
182
+ if (sort && typeof q.sort === 'function') {
183
+ q = q.sort(sort);
184
+ }
185
+
186
+ if (op === 'find' && limit !== undefined && limit !== null && typeof q.limit === 'function') {
187
+ const n = parseInt(String(limit), 10);
188
+ if (Number.isFinite(n) && n > 0) q = q.limit(n);
189
+ }
190
+
191
+ if (typeof q.lean === 'function') q = q.lean();
192
+
193
+ const result = await q;
194
+ pageContext.vars[assignTo] = result;
195
+ return result;
196
+ }
197
+
198
+ async function runServiceInvokeBlock(block, pageContext) {
199
+ const props = block?.props || {};
200
+
201
+ const servicePath = String(props.servicePath || '').trim();
202
+ if (!servicePath) throw Object.assign(new Error('service.invoke requires props.servicePath'), { code: 'VALIDATION' });
203
+
204
+ const assignTo = String(props.assignTo || '').trim();
205
+ if (!assignTo) throw Object.assign(new Error('service.invoke requires props.assignTo'), { code: 'VALIDATION' });
206
+
207
+ const ctxRoot = {
208
+ pageContext,
209
+ auth: pageContext.auth,
210
+ session: pageContext.session,
211
+ vars: pageContext.vars,
212
+ params: pageContext.params,
213
+ query: pageContext.query,
214
+ };
215
+
216
+ const args = interpolateCtx(props.args || [], ctxRoot);
217
+
218
+ const fn = getByPath(pageContext.helpers, servicePath);
219
+ if (typeof fn !== 'function') {
220
+ throw Object.assign(new Error(`service.invoke target is not a function: ${servicePath}`), { code: 'VALIDATION' });
221
+ }
222
+
223
+ const result = await fn(...(Array.isArray(args) ? args : [args]));
224
+ pageContext.vars[assignTo] = result;
225
+ return result;
226
+ }
227
+
228
+ function defaultCacheKeyForBlock({ pageId, routePath, block }) {
229
+ return sha1(JSON.stringify({ pageId, routePath, block }));
230
+ }
231
+
232
+ async function runContextBlock(block, { pageId, routePath, pageContext }) {
233
+ const type = String(block?.type || '').trim();
234
+
235
+ const cache = block?.props?.cache || null;
236
+ const cacheEnabled = Boolean(cache?.enabled);
237
+ const namespace = cache?.namespace ? String(cache.namespace) : 'pages:ssr';
238
+ const ttlSeconds = cache?.ttlSeconds === undefined ? undefined : cache.ttlSeconds;
239
+
240
+ const timeoutEnabled = Boolean(block?.props?.timeout?.enabled);
241
+ const timeoutMsRaw = block?.props?.timeout?.ms || block?.props?.timeout?.value || null;
242
+ const defaultTimeoutMs = await getDefaultTimeoutMs();
243
+ const timeoutMs = parseDurationToMs(timeoutMsRaw) ?? defaultTimeoutMs;
244
+
245
+ const compute = async () => {
246
+ if (type === 'context.db_query') {
247
+ return runDbQueryBlock(block, pageContext);
248
+ }
249
+ if (type === 'context.service_invoke') {
250
+ return runServiceInvokeBlock(block, pageContext);
251
+ }
252
+ throw Object.assign(new Error(`Unknown context block type: ${type}`), { code: 'VALIDATION' });
253
+ };
254
+
255
+ const run = async () => withOptionalTimeout(compute(), { enabled: timeoutEnabled, timeoutMs });
256
+
257
+ if (!cacheEnabled) {
258
+ return run();
259
+ }
260
+
261
+ const key = cache?.key
262
+ ? String(interpolateCtx(cache.key, { pageContext, vars: pageContext.vars, params: pageContext.params, query: pageContext.query, auth: pageContext.auth, session: pageContext.session }))
263
+ : defaultCacheKeyForBlock({ pageId, routePath, block });
264
+
265
+ const cached = await cacheLayer.get(key, { namespace }).catch(() => null);
266
+ if (cached !== null && cached !== undefined) {
267
+ return cached;
268
+ }
269
+
270
+ const value = await run();
271
+ await cacheLayer.set(key, value, { namespace, ttlSeconds }).catch(() => {});
272
+ return value;
273
+ }
274
+
275
+ function splitBlocks(page) {
276
+ const blocks = Array.isArray(page?.blocks) ? page.blocks : [];
277
+ const contextBlocks = [];
278
+ const renderBlocks = [];
279
+
280
+ for (const b of blocks) {
281
+ const t = String(b?.type || '').trim();
282
+ if (t.startsWith('context.')) {
283
+ contextBlocks.push(b);
284
+ } else {
285
+ renderBlocks.push(b);
286
+ }
287
+ }
288
+
289
+ return { contextBlocks, renderBlocks };
290
+ }
291
+
292
+ async function resolvePageContext({ page, req, res, routePath, params = {}, mockContext = null }) {
293
+ const pageContext = {
294
+ vars: {},
295
+ helpers: buildHelpers(),
296
+ auth: buildAuthContext(req),
297
+ session: buildSessionContext(req),
298
+ params: params || {},
299
+ query: (req && req.query) ? req.query : {},
300
+ request: {
301
+ path: routePath || (req ? req.path : null),
302
+ method: req ? req.method : null,
303
+ },
304
+ };
305
+
306
+ if (mockContext && typeof mockContext === 'object') {
307
+ if (mockContext.auth !== undefined) pageContext.auth = mockContext.auth;
308
+ if (mockContext.session !== undefined) pageContext.session = mockContext.session;
309
+ if (mockContext.params !== undefined) pageContext.params = mockContext.params;
310
+ if (mockContext.query !== undefined) pageContext.query = mockContext.query;
311
+ }
312
+
313
+ const { contextBlocks, renderBlocks } = splitBlocks(page);
314
+
315
+ for (const block of contextBlocks) {
316
+ await runContextBlock(block, {
317
+ pageId: page?._id ? String(page._id) : null,
318
+ routePath: routePath || (req ? req.path : null),
319
+ pageContext,
320
+ });
321
+ }
322
+
323
+ return { pageContext, contextBlocks, renderBlocks };
324
+ }
325
+
326
+ module.exports = {
327
+ parseDurationToMs,
328
+ getDefaultTimeoutMs,
329
+ interpolateCtx,
330
+ resolvePageContext,
331
+ };
@@ -0,0 +1,349 @@
1
+ const llmService = require('./llm.service');
2
+ const { resolveLlmProviderModel } = require('./llmDefaults.service');
3
+ const { createAuditEvent } = require('./audit.service');
4
+
5
+ const ALLOWED_BLOCK_TYPES = new Set(['context.db_query', 'context.service_invoke']);
6
+
7
+ function parseJsonFromModelOutput(raw) {
8
+ const text = String(raw || '').trim();
9
+
10
+ try {
11
+ return JSON.parse(text);
12
+ } catch (_) {
13
+ const m = text.match(/```json\s*([\s\S]*?)\s*```/i) || text.match(/```\s*([\s\S]*?)\s*```/i);
14
+ if (m) {
15
+ return JSON.parse(String(m[1] || '').trim());
16
+ }
17
+
18
+ const idx = text.indexOf('{');
19
+ const last = text.lastIndexOf('}');
20
+ if (idx !== -1 && last !== -1 && last > idx) {
21
+ return JSON.parse(text.slice(idx, last + 1));
22
+ }
23
+
24
+ const err = new Error('AI response was not valid JSON');
25
+ err.code = 'AI_INVALID';
26
+ throw err;
27
+ }
28
+ }
29
+
30
+ function normalizeBlockType(v) {
31
+ return String(v || '').trim();
32
+ }
33
+
34
+ function validateProposalShape(obj) {
35
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
36
+ const err = new Error('AI proposal must be a JSON object');
37
+ err.code = 'AI_INVALID';
38
+ throw err;
39
+ }
40
+
41
+ const type = normalizeBlockType(obj.type);
42
+ if (!ALLOWED_BLOCK_TYPES.has(type)) {
43
+ const err = new Error(`AI proposal type must be one of: ${Array.from(ALLOWED_BLOCK_TYPES).join(', ')}`);
44
+ err.code = 'AI_INVALID';
45
+ throw err;
46
+ }
47
+
48
+ const props = obj.props;
49
+ if (!props || typeof props !== 'object' || Array.isArray(props)) {
50
+ const err = new Error('AI proposal props must be an object');
51
+ err.code = 'AI_INVALID';
52
+ throw err;
53
+ }
54
+
55
+ if (props.cache !== undefined && props.cache !== null) {
56
+ if (typeof props.cache !== 'object' || Array.isArray(props.cache)) {
57
+ const err = new Error('AI proposal props.cache must be an object');
58
+ err.code = 'AI_INVALID';
59
+ throw err;
60
+ }
61
+ if (props.cache.ttlSeconds !== undefined && props.cache.ttlSeconds !== null) {
62
+ const n = Number(props.cache.ttlSeconds);
63
+ if (!Number.isFinite(n) || n < 0) {
64
+ const err = new Error('AI proposal props.cache.ttlSeconds must be a number >= 0');
65
+ err.code = 'AI_INVALID';
66
+ throw err;
67
+ }
68
+ }
69
+ }
70
+
71
+ if (props.timeout !== undefined && props.timeout !== null) {
72
+ if (typeof props.timeout !== 'object' || Array.isArray(props.timeout)) {
73
+ const err = new Error('AI proposal props.timeout must be an object');
74
+ err.code = 'AI_INVALID';
75
+ throw err;
76
+ }
77
+ if (props.timeout.value !== undefined && props.timeout.value !== null && typeof props.timeout.value !== 'string') {
78
+ const err = new Error('AI proposal props.timeout.value must be a string');
79
+ err.code = 'AI_INVALID';
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ return { type, props };
85
+ }
86
+
87
+ function buildHelpersContextForPrompt() {
88
+ const sb = globalThis.superbackend || globalThis.saasbackend || null;
89
+ const services = (sb && sb.services) ? sb.services : {};
90
+ const models = (sb && sb.models) ? sb.models : {};
91
+
92
+ const denyServices = new Set(['globalSettings', 'migration', 'workflow']);
93
+
94
+ const serviceKeys = Object.keys(services || {}).filter((k) => !denyServices.has(k)).sort();
95
+ const modelKeys = Object.keys(models || {}).sort();
96
+
97
+ return {
98
+ denyServices: Array.from(denyServices),
99
+ serviceKeys,
100
+ modelKeys,
101
+ };
102
+ }
103
+
104
+ function capList(list, max) {
105
+ const arr = Array.isArray(list) ? list : [];
106
+ if (arr.length <= max) return arr;
107
+ return arr.slice(0, max);
108
+ }
109
+
110
+ function buildSystemPrompt({ helpersInfo }) {
111
+ const servicesPreview = capList(helpersInfo.serviceKeys, 80);
112
+ const modelsPreview = capList(helpersInfo.modelKeys, 80);
113
+
114
+ return [
115
+ 'You are an assistant that outputs a single JSON object describing a Pages SSR Context Block.',
116
+ 'Return ONLY JSON. No markdown, no extra keys, no explanation.',
117
+ '',
118
+ 'Output schema:',
119
+ '{',
120
+ ' "type": "context.db_query|context.service_invoke",',
121
+ ' "props": { ... }',
122
+ '}',
123
+ '',
124
+ 'Allowed type values: context.db_query, context.service_invoke',
125
+ '',
126
+ 'Shared props patterns:',
127
+ '- "assignTo": string key for pageContext.vars (required)',
128
+ '- "cache": { "enabled": boolean, "namespace"?: string, "ttlSeconds"?: number, "key"?: any } (optional)',
129
+ '- "timeout": { "enabled": boolean, "value"?: "250ms"|"5s"|"1m" } (optional)',
130
+ '',
131
+ '$ctx interpolation:',
132
+ 'You can reference runtime values inside props using a JSON object with a single key "$ctx".',
133
+ 'Example: {"slug": {"$ctx": "params.slug"}}',
134
+ '',
135
+ 'Allowed $ctx roots:',
136
+ '- params.* (repeat params like params.slug)',
137
+ '- query.* (req.query)',
138
+ '- auth.*',
139
+ '- session.*',
140
+ '- vars.* (results from previous context blocks)',
141
+ '- pageContext.*',
142
+ '',
143
+ 'context.db_query props:',
144
+ '{',
145
+ ' "model": "MongooseModelName",',
146
+ ' "op": "find|findOne|countDocuments",',
147
+ ' "filter": { ... },',
148
+ ' "sort"?: { ... },',
149
+ ' "select"?: { ... },',
150
+ ' "limit"?: number,',
151
+ ' "assignTo": "post"',
152
+ '}',
153
+ '',
154
+ 'context.service_invoke props:',
155
+ '{',
156
+ ' "servicePath": "services.someService.someFn" | "models.SomeModel.someStatic" | "mongoose.someFn",',
157
+ ' "args": [ ... ] | <any>,',
158
+ ' "assignTo": "result"',
159
+ '}',
160
+ '',
161
+ 'Invokable helper namespaces:',
162
+ '- helpers.services.<serviceName>.*',
163
+ '- helpers.models.<ModelName>.*',
164
+ '- helpers.mongoose.*',
165
+ '',
166
+ `services available (preview): ${JSON.stringify(servicesPreview)}`,
167
+ `models available (preview): ${JSON.stringify(modelsPreview)}`,
168
+ `services denylist (cannot be referenced): ${JSON.stringify(helpersInfo.denyServices)}`,
169
+ '',
170
+ 'Examples:',
171
+ '{"type":"context.db_query","props":{"model":"BlogPost","op":"findOne","filter":{"slug":{"$ctx":"params.slug"},"status":"published"},"assignTo":"post"}}',
172
+ '{"type":"context.db_query","props":{"model":"BlogPost","op":"find","filter":{"status":"published"},"sort":{"publishedAt":-1},"limit":10,"assignTo":"latestPosts","cache":{"enabled":true,"ttlSeconds":30,"key":{"$ctx":"params.slug"}}}}',
173
+ '{"type":"context.service_invoke","props":{"servicePath":"services.i18n.translate","args":[{"$ctx":"query.text"}],"assignTo":"t"}}',
174
+ ].join('\n');
175
+ }
176
+
177
+ function computeWarnings({ proposal }) {
178
+ const warnings = [];
179
+ const props = proposal?.props || {};
180
+
181
+ if (props.cache && props.cache.enabled && !Object.prototype.hasOwnProperty.call(props.cache, 'key')) {
182
+ warnings.push('Caching is enabled but props.cache.key is missing; key will be auto-derived (may be OK but can reduce cache hit rate).');
183
+ }
184
+
185
+ if (proposal?.type === 'context.db_query') {
186
+ const limit = props.limit;
187
+ const n = limit === undefined || limit === null ? null : Number(limit);
188
+ if (n !== null && Number.isFinite(n) && n > 200) {
189
+ warnings.push('db_query limit is > 200; consider lowering it to reduce SSR latency and payload size.');
190
+ }
191
+ }
192
+
193
+ if (proposal?.type === 'context.service_invoke') {
194
+ const sp = String(props.servicePath || '');
195
+ if (sp.startsWith('services.globalSettings') || sp.startsWith('services.migration') || sp.startsWith('services.workflow')) {
196
+ warnings.push('servicePath references a denylisted service namespace and will fail at runtime.');
197
+ }
198
+ }
199
+
200
+ return warnings;
201
+ }
202
+
203
+ async function resolveLlmDefaults({ systemKey, providerKey, model }) {
204
+ return resolveLlmProviderModel({ systemKey, providerKey, model });
205
+ }
206
+
207
+ async function generateContextBlock({ prompt, blockType, providerKey, model, actor }) {
208
+ const instruction = String(prompt || '').trim();
209
+ if (!instruction) {
210
+ const err = new Error('prompt is required');
211
+ err.code = 'VALIDATION';
212
+ throw err;
213
+ }
214
+
215
+ const type = normalizeBlockType(blockType);
216
+ if (!ALLOWED_BLOCK_TYPES.has(type)) {
217
+ const err = new Error(`blockType must be one of: ${Array.from(ALLOWED_BLOCK_TYPES).join(', ')}`);
218
+ err.code = 'VALIDATION';
219
+ throw err;
220
+ }
221
+
222
+ const helpersInfo = buildHelpersContextForPrompt();
223
+
224
+ const llmDefaults = await resolveLlmDefaults({
225
+ systemKey: 'pageBuilder.blocks.generate',
226
+ providerKey,
227
+ model,
228
+ });
229
+
230
+ const result = await llmService.callAdhoc(
231
+ {
232
+ providerKey: llmDefaults.providerKey,
233
+ model: llmDefaults.model,
234
+ messages: [
235
+ { role: 'system', content: buildSystemPrompt({ helpersInfo }) },
236
+ { role: 'user', content: `Block type: ${type}\nInstruction:\n${instruction}` },
237
+ ],
238
+ promptKeyForAudit: 'pages.contextBlocks.ai.generate',
239
+ },
240
+ { temperature: 0.2 },
241
+ );
242
+
243
+ const raw = String(result.content || '');
244
+ const json = parseJsonFromModelOutput(raw);
245
+ const proposal = validateProposalShape(json);
246
+
247
+ if (proposal.type !== type) {
248
+ const err = new Error('AI proposal type must match requested blockType');
249
+ err.code = 'AI_INVALID';
250
+ throw err;
251
+ }
252
+
253
+ const warnings = computeWarnings({ proposal });
254
+
255
+ await createAuditEvent({
256
+ ...(actor || { actorType: 'system', actorId: null }),
257
+ action: 'pages.contextBlocks.ai.generate',
258
+ entityType: 'PagesContextBlock',
259
+ entityId: proposal.type,
260
+ before: null,
261
+ after: { type: proposal.type },
262
+ meta: {
263
+ providerKey: llmDefaults.providerKey,
264
+ model: llmDefaults.model,
265
+ responsePreview: raw.slice(0, 4000),
266
+ warnings,
267
+ },
268
+ });
269
+
270
+ return {
271
+ proposal,
272
+ providerKey: llmDefaults.providerKey,
273
+ model: llmDefaults.model,
274
+ warnings,
275
+ };
276
+ }
277
+
278
+ async function proposeContextBlockEdit({ prompt, currentBlock, providerKey, model, actor }) {
279
+ const instruction = String(prompt || '').trim();
280
+ if (!instruction) {
281
+ const err = new Error('prompt is required');
282
+ err.code = 'VALIDATION';
283
+ throw err;
284
+ }
285
+
286
+ const current = validateProposalShape(currentBlock);
287
+
288
+ const helpersInfo = buildHelpersContextForPrompt();
289
+
290
+ const llmDefaults = await resolveLlmDefaults({
291
+ systemKey: 'pageBuilder.blocks.propose',
292
+ providerKey,
293
+ model,
294
+ });
295
+
296
+ const result = await llmService.callAdhoc(
297
+ {
298
+ providerKey: llmDefaults.providerKey,
299
+ model: llmDefaults.model,
300
+ messages: [
301
+ { role: 'system', content: buildSystemPrompt({ helpersInfo }) },
302
+ { role: 'user', content: `Instruction:\n${instruction}` },
303
+ { role: 'user', content: `Current block:\n${JSON.stringify(current, null, 2)}` },
304
+ ],
305
+ promptKeyForAudit: 'pages.contextBlocks.ai.propose',
306
+ },
307
+ { temperature: 0.2 },
308
+ );
309
+
310
+ const raw = String(result.content || '');
311
+ const json = parseJsonFromModelOutput(raw);
312
+ const proposal = validateProposalShape(json);
313
+
314
+ if (proposal.type !== current.type) {
315
+ const err = new Error('AI proposal type must match currentBlock type');
316
+ err.code = 'AI_INVALID';
317
+ throw err;
318
+ }
319
+
320
+ const warnings = computeWarnings({ proposal });
321
+
322
+ await createAuditEvent({
323
+ ...(actor || { actorType: 'system', actorId: null }),
324
+ action: 'pages.contextBlocks.ai.propose',
325
+ entityType: 'PagesContextBlock',
326
+ entityId: proposal.type,
327
+ before: { type: current.type },
328
+ after: { type: proposal.type },
329
+ meta: {
330
+ providerKey: llmDefaults.providerKey,
331
+ model: llmDefaults.model,
332
+ responsePreview: raw.slice(0, 4000),
333
+ warnings,
334
+ },
335
+ });
336
+
337
+ return {
338
+ currentBlock: current,
339
+ proposal,
340
+ providerKey: llmDefaults.providerKey,
341
+ model: llmDefaults.model,
342
+ warnings,
343
+ };
344
+ }
345
+
346
+ module.exports = {
347
+ generateContextBlock,
348
+ proposeContextBlockEdit,
349
+ };