@gov-cy/govcy-express-services 0.1.5 → 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/README.md CHANGED
@@ -140,7 +140,13 @@ Here are some details explaining the JSON structure:
140
140
  - `submission_data_version` : The submission data version,
141
141
  - `renderer_version` : The govcy-frontend-renderer version,
142
142
  - `design_systems_version` : The govcy-design-system version,
143
- - `homeRedirectPage`: The page to redirect when user visits the route page. Usually this will redirect to gov.cy page. If not provided will show a list of available sites.
143
+ - `homeRedirectPage`: An object mapping language codes to URLs. When a user visits the root route (e.g., `https://whatever-your-service-is.service.gov.cy/`), the system redirects to the URL for the user's language. If the user's language is not found, it falls back to `"el"` or the first available URL. If not provided, a list of available sites is shown. Example:
144
+ ```json
145
+ "homeRedirectPage": {
146
+ "el": "https://www.gov.cy/service/aitisi-gia-taftotita/",
147
+ "en": "https://www.gov.cy/en/service/issue-an-id-card/"
148
+ }
149
+ ```
144
150
  - `matomo `: The Matomo web analytics configuration details.
145
151
  - `eligibilityAPIEndpoints` : An array of API endpoints, to be used for service eligibility. See more on the [Eligibility API Endoints](#%EF%B8%8F-site-eligibility-checks) section below.
146
152
  - `submissionAPIEndpoint`: The submission API endpoint, to be used for submitting the form. See more on the [Submission API Endoint](#-site-submissions) section below.
@@ -298,6 +304,9 @@ When designing form pages, refer to the Unified Design System's [question pages
298
304
  **Error pages**
299
305
  Pages that can be used to display messages when eligibility or submission fail are simply static content pages. That is pages that do not include a `form` element.
300
306
 
307
+ **Start page**
308
+ The [start page](https://gov-cy.github.io/govcy-design-system-docs/patterns/service_structure/#start-page) should be created in the gov.cy portal and should be defined in the `site.homeRedirectPage` property in the site config JSON file. All pages within a service are private by default and can only be accessed by authenticated users, so the start page cannot be created in the JSON file.
309
+
301
310
  **Notes**:
302
311
  - Check out the [govcy-frontend-renderer's design elements](https://github.com/gov-cy/govcy-frontend-renderer/blob/main/DESIGN_ELEMENTS.md) for more details on the supported elements and their parameters.
303
312
  - Check out the [input validations section](#-input-validations) for more details on how to add validations to the JSON file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gov-cy/govcy-express-services",
3
- "version": "0.1.5",
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
  }
@@ -14,6 +14,18 @@ export function govcyRoutePageHandler(req, res, next) {
14
14
  const siteId = req.cookies.cs;
15
15
  const serviceData = getServiceConfigData(siteId, req.globalLang);
16
16
  if (serviceData.site && serviceData.site.homeRedirectPage) {
17
+ const homeRedirectPage = serviceData.site.homeRedirectPage;
18
+ const lang = req.globalLang || 'el'; // fallback to 'en' if not set
19
+
20
+ let redirectUrl = null;
21
+
22
+ if (homeRedirectPage && typeof homeRedirectPage === 'object') {
23
+ redirectUrl = homeRedirectPage[lang] || homeRedirectPage['el'] || Object.values(homeRedirectPage)[0];
24
+ }
25
+
26
+ if (redirectUrl) {
27
+ return res.redirect(redirectUrl);
28
+ }
17
29
  // redirect to the homeRedirectPage cookie
18
30
  return res.redirect(serviceData.site.homeRedirectPage);
19
31
  }
@@ -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