@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`:
|
|
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.
|
|
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
|
-
//
|
|
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
|