@gov-cy/govcy-express-services 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gov-cy/govcy-express-services",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "An Express-based system that dynamically renders services using @gov-cy/govcy-frontend-renderer and posts data to a submission API.",
5
5
  "author": "DMRID - DSF Team",
6
6
  "license": "MIT",
@@ -3,6 +3,7 @@ import { populateFormData } from "../utils/govcyFormHandling.mjs";
3
3
  import * as govcyResources from "../resources/govcyResources.mjs";
4
4
  import * as dataLayer from "../utils/govcyDataLayer.mjs";
5
5
  import { logger } from "../utils/govcyLogger.mjs";
6
+ // import {flattenContext, evaluateExpressionWithFlattening, evaluatePageConditions } from "../utils/govcyExpressions.mjs";
6
7
 
7
8
  /**
8
9
  * Middleware to handle page rendering and form processing
@@ -30,7 +31,8 @@ export function govcyPageHandler() {
30
31
  // Deep copy pageTemplate to avoid modifying the original
31
32
  const pageTemplateCopy = JSON.parse(JSON.stringify(page.pageTemplate));
32
33
 
33
- //if user is logged in add he user bane section in the page template
34
+ // TODO: Conditional logic comes here
35
+ //if user is logged in add the user nane section in the page template
34
36
  if (dataLayer.getUser(req.session)) {
35
37
  pageTemplateCopy.sections.push(govcyResources.userNameSection(dataLayer.getUser(req.session).name)); // Add user name section
36
38
  }
@@ -246,6 +246,23 @@ export function getSiteSubmissionErrors(store, siteId) {
246
246
  return null;
247
247
  }
248
248
 
249
+ /**
250
+ * Get the site's data from the store (including input data and eligibility data)
251
+ *
252
+ * @param {object} store The session store
253
+ * @param {string} siteId |The site id
254
+ * @returns The site data or null if none exist.
255
+ */
256
+ export function getSiteData(store, siteId) {
257
+ const inputData = store?.siteData?.[siteId] || {};
258
+
259
+ if (inputData) {
260
+ return inputData;
261
+ }
262
+
263
+ return null;
264
+ }
265
+
249
266
  /**
250
267
  * Get the site's input data from the store
251
268
  *
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @module govcyExpressions
3
+ *
4
+ * This module provides utility functions for evaluating JavaScript expressions
5
+ * with a flattened context, ensuring safe execution and preventing unsafe patterns.
6
+ *
7
+ */
8
+ import { logger } from "../utils/govcyLogger.mjs";
9
+ import * as dataLayer from "../utils/govcyDataLayer.mjs";
10
+
11
+ /**
12
+ * Recursively flattens nested objects into dot-path keys.
13
+ * E.g. { a: { b: { c: 5 } } } → { "a.b.c": 5 }
14
+ *
15
+ * Example usage:
16
+ * ```javascript
17
+ * flattenContext(req.session.siteData["testService"],"testService")
18
+ * ```
19
+ * This will output:
20
+ * ```json
21
+ * {
22
+ "testService.inputData.index.formData.certificate_select": [
23
+ "birth",
24
+ "permanent_residence"
25
+ ],
26
+ "testService.inputData.page1.formData.mobile_select": "mobile",
27
+ "testService.inputData.page1.formData.mobileTxt": "",
28
+ "testService.inputData.page2.formData.mobile": "+35722404383",
29
+ "testService.inputData.page2.formData.dateGot_day": "12",
30
+ "testService.inputData.page2.formData.dateGot_month": "10",
31
+ "testService.inputData.page2.formData.dateGot_year": "1212",
32
+ "testService.inputData.page2.formData.appointment": "10/06/2025"
33
+ }
34
+ *
35
+ * @param {object} obj - The object to flatten
36
+ * @param {string} prefix - The prefix for nested keys (used internally)
37
+ * @param {object} res - The result object to store flattened keys (used internally)
38
+ * @returns {object} - Flattened object with dot-path keys
39
+ */
40
+ export function flattenContext(obj, prefix = '', res = {}) {
41
+ for (const [key, val] of Object.entries(obj)) {
42
+ const path = prefix ? `${prefix}.${key}` : key;
43
+ if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
44
+ flattenContext(val, path, res);
45
+ } else {
46
+ res[path] = val;
47
+ }
48
+ }
49
+ return res;
50
+ }
51
+
52
+ /**
53
+ * Validates a JavaScript expression string against a blocklist of unsafe patterns.
54
+ * Throws an error if any unsafe pattern is found.
55
+ *
56
+ * @throws {Error} If the expression contains any unsafe patterns
57
+ * @param {string} expression - The JavaScript expression to validate
58
+ * @returns {void}
59
+ */
60
+ function validateExpression(expression) {
61
+ const blocklist = [
62
+ 'while(', 'for(', 'do{', // loops
63
+ 'Function(', 'constructor(', // access to internals
64
+ 'process', 'require', // Node.js access
65
+ 'global', 'this', 'eval', // execution context or scope tricks
66
+ '__proto__', 'prototype', // prototype chain abuse
67
+ 'setTimeout', 'setInterval', // async/loops
68
+ ];
69
+
70
+ for (const forbidden of blocklist) {
71
+ if (expression.includes(forbidden)) {
72
+ logger.debug(`Blocked unsafe expression: contains "${forbidden}"`, expression);
73
+ throw new Error(`Blocked unsafe expression: contains "${forbidden}"`);
74
+ }
75
+ }
76
+ }
77
+
78
+
79
+ /**
80
+ * Evaluates a JavaScript expression string with a given context.
81
+ *
82
+ * @param {string} expression - The JavaScript expression to evaluate
83
+ * @param {object} dataLayer - The context object containing data for evaluation
84
+ * @throws {Error} If the expression is invalid or contains unsafe patterns
85
+ * @returns
86
+ */
87
+ export function evaluateExpression(expression, dataLayer = {}) {
88
+ validateExpression(expression); // ⛔️ Blocks known unsafe patterns
89
+
90
+ // The expression will reference: dataLayer["some.path.key"]
91
+ console.debug(`Evaluating expression: ${expression}`, dataLayer);
92
+ const fn = new Function('dataLayer', `return (${expression});`);
93
+ return fn(dataLayer);
94
+ }
95
+
96
+
97
+ /**
98
+ * Evaluates expression **with automatic flattening**.
99
+ * This is a convenience wrapper for most use cases.
100
+ *
101
+ * @param {string} expression - JS expression using dataLayer["..."]
102
+ * @param {object} object - Unflattened data object (e.g., session.siteData[siteKey])
103
+ * @param {string} prefix - Prefix to add to all keys (usually the siteKey)
104
+ * @returns {*} - The evaluated result
105
+ */
106
+ export function evaluateExpressionWithFlattening(expression, object, prefix = '') {
107
+ const dataLayer = flattenContext(object, prefix);
108
+ return evaluateExpression(expression, dataLayer);
109
+ }
110
+
111
+
112
+ /**
113
+ * Evaluates whether a page should be rendered or redirected based on its conditions.
114
+ *
115
+ * - Conditions are located in `page.pageData.conditions`
116
+ * - Each condition must have a valid `expression` and `redirect`
117
+ * - If an expression evaluates to `true`, the user is redirected
118
+ * - If all expressions fail (or none exist), the page is rendered
119
+ *
120
+ * @param {object} page - The page object containing `pageData.conditions`
121
+ * @param {object} store - The full session object (e.g., `req.session`)
122
+ * @param {string} siteKey - The key for the current site context (e.g., `"nsf2"`)
123
+ * @param {object} req - The request object (used to track redirect depth)
124
+ * @returns {{ result: true } | { result: false, redirect: string }}
125
+ * An object indicating whether to render the page or redirect
126
+ */
127
+ export function evaluatePageConditions(page, store, siteKey, req) {
128
+ // Get conditions array from nested page structure
129
+ const conditions = page?.pageData?.conditions;
130
+
131
+ // --- Protect against infinite redirects ---
132
+ const depth = req?._pageRedirectDepth || 0; // Track redirect depth per request (resets every HTTP call)
133
+ if (depth > 10) { // Safer default max limit
134
+ logger.debug(`Max redirect depth exceeded for siteKey '${siteKey}'`);
135
+ return { result: true };
136
+ }
137
+
138
+ // If no conditions or not an array, render the page by default
139
+ if (!Array.isArray(conditions) || conditions.length === 0) {
140
+ logger.debug(`No conditions found for page '${page.pageData?.url || page.id}'`);
141
+ return { result: true };
142
+ }
143
+
144
+ // Get scoped session data for this site
145
+ const siteData = dataLayer.getSiteData(store, siteKey);
146
+ if (!siteData || typeof siteData !== 'object') {
147
+ logger.debug(`No site data found for siteKey '${siteKey}' on page '${page.pageData?.url || page.id}'`);
148
+ return { result: true }; // If site data is missing, continue safely
149
+ }
150
+
151
+ // Evaluate each condition
152
+ for (const [i, condition] of conditions.entries()) {
153
+ // Ensure the condition is well-formed
154
+ const isValid =
155
+ typeof condition === 'object' &&
156
+ condition !== null &&
157
+ typeof condition.expression === 'string' &&
158
+ condition.expression.trim() !== '' &&
159
+ typeof condition.redirect === 'string' &&
160
+ condition.redirect.trim() !== '';
161
+
162
+ if (!isValid) {
163
+ logger.debug(`Skipping invalid condition at index ${i} on page '${page.pageData?.url || page.id}'`, condition);
164
+ continue;
165
+ }
166
+
167
+ try {
168
+ // Evaluate the expression using flattened site data
169
+ const result = evaluateExpressionWithFlattening(
170
+ condition.expression,
171
+ siteData,
172
+ siteKey
173
+ );
174
+
175
+ // ✅ If expression is true → trigger redirect
176
+ if (result === true) { // Only redirect if explicitly `true`
177
+ // --- Protect against infinite redirects ---
178
+ req._pageRedirectDepth = depth + 1; // Increment redirect counter only if redirecting
179
+ // return redirect response
180
+ return { result: false, redirect: condition.redirect };
181
+ }
182
+ } catch (err) {
183
+ logger.debug(`Expression error in condition[${i}] on page '${page.pageData?.url || page.id}':`, condition.expression);
184
+ logger.debug('→', err.message);
185
+ // ❌ Expression threw an error — skip and evaluate next
186
+ }
187
+ }
188
+
189
+ // ✅ All expressions were false or skipped → allow page rendering
190
+ return { result: true };
191
+ }
192
+
193
+
194
+
195
+ // console.log(evaluateExpression(
196
+ // '(dataLayer["test.inputData.index.formData.certificate_select"] == "permanent_residence") || (dataLayer["test.eligibility.TEST_ELIGIBILITY_2_API_URL.result.Succeeded"] == false)',
197
+ // flattenContext(req.session.siteData.test,'test')));
198
+
199
+
200
+ // console.log(evaluatePageConditions(page, req.session, siteId));
201
+
202
+
203
+ // {
204
+ // "pageData": {
205
+ // "url": "data-entry-textinput",
206
+ // "title": {
207
+ // "el": "Σταθερό τηλέφωνο",
208
+ // "en": "Landline number",
209
+ // "tr": ""
210
+ // },
211
+ // "layout": "layouts/govcyBase.njk",
212
+ // "mainLayout": "two-third",
213
+ // "nextPage": "data-entry-all",
214
+ // "conditions": [
215
+ // {
216
+ // "expression": "dataLayer['test.eligibility.TEST_ELIGIBILITY_1_API_URL.result.Succeeded'] == false || dataLayer['test.inputData.data-entry-radios.formData.mobile_select'] == 'mobile'",
217
+ // "redirect": "review"
218
+ // },
219
+ // {
220
+ // "expression": "dataLayer['test.eligibility.TEST_ELIGIBILITY_1_API_URL.result.Succeeded'] == false",
221
+ // "redirect": "review"
222
+ // }
223
+ // ]
224
+ // },
225
+
226
+ // Todo:
227
+ // - [X] Add unit tests
228
+ // - [ ] Add page with conditions in JSON
229
+ // - [ ] Add logic on the page handler to evaluate conditions
230
+ // - [ ] Add logic on review and review post page handler to evaluate the conditions
231
+ // - [ ] Add Functional tests for the conditions
232
+ // - [ ] Add documentation for the conditions