@gov-cy/govcy-express-services 1.4.3 → 1.6.0

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
@@ -30,6 +30,8 @@ The APIs used for submission, temporary save and file uploads are not part of th
30
30
  - [✅ Best Practices](#-best-practices)
31
31
  - [📦 Full installation guide](#-full-installation-guide)
32
32
  - [🛠️ Usage](#%EF%B8%8F-usage)
33
+ - [🔑 Authentication Middleware](#-authentication-middleware)
34
+ - [cyLogin Access Policies](#cylogin-access-policies)
33
35
  - [🧩 Dynamic services](#-dynamic-services)
34
36
  - [Pages](#pages)
35
37
  - [Form vs static pages](#form-vs-static-pages)
@@ -142,13 +144,67 @@ npm start
142
144
  ```
143
145
  The server will start on `https://localhost:44319` (see [NOTES.md](NOTES.md#local-development) for more details on this).
144
146
 
145
- ### Authentication Middleware
147
+ ### 🔑 Authentication Middleware
146
148
  Authentication is handled via OpenID Connect using CY Login and is configured using environment variables. The middleware ensures users have valid sessions before accessing protected routes.
147
149
 
148
150
  The CY Login tokens are used to also connect with the various APIs through [cyConnect](https://dev.azure.com/cyprus-gov-cds/Documentation/_wiki/wikis/Documentation/74/CY-Connect), so make sure to include the correct `scope` when requesting for a [cyLogin client registration](https://dev.azure.com/cyprus-gov-cds/Documentation/_wiki/wikis/Documentation/34/Developer-Guide).
149
151
 
150
152
  The CY Login settings are configured in the `secrets/.env` file.
151
153
 
154
+ #### cyLogin Access Policies
155
+
156
+ Each service can specify which types of authenticated CY Login profiles are allowed to access it using the `site.cyLoginPolicies` property in its site configuration.
157
+
158
+ ```json
159
+ "cyLoginPolicies": ["naturalPerson", "legalPerson"]
160
+ ```
161
+
162
+ ##### Supported Policies
163
+
164
+ | Policy name | Description | Typical use |
165
+ | --------------- | ------------------------------------------------------------ | ---------------------------------------------------- |
166
+ | `naturalPerson` | Allows individual users (Cypriot citizens or foreign residents) who have a verified profile in the Civil Registry. Identified by `profile_type: "Individual"` and a 10-digit identifier starting with `00` (citizen) or `05` (foreigner). | Citizen-facing services, personal applications, etc. |
167
+ | `legalPerson` | Allows legal entities (companies, partnerships, organisations) with verified profiles in the Registrar of Companies. Identified by `profile_type: "Organisation"` and a `legal_unique_identifier`. | Business-facing services, company submissions, etc. |
168
+
169
+ ##### How it works
170
+
171
+ - Access is granted if **any** of the listed policies pass.
172
+ - If the user’s CY Login profile does not match any of the allowed policies, the request is blocked.
173
+
174
+ ##### Defaults
175
+
176
+ If `cyLoginPolicies` is omitted, the framework defaults to:
177
+
178
+ ```json
179
+ "cyLoginPolicies": ["naturalPerson"]
180
+ ```
181
+
182
+ This maintains backward compatibility with existing services that only supported individual (civil registry) users.
183
+
184
+ ##### Example
185
+
186
+ Allow both natural and legal persons:
187
+
188
+ ```json
189
+ "site": {
190
+ "cyLoginPolicies": ["naturalPerson", "legalPerson"]
191
+ }
192
+ ```
193
+
194
+ Restrict access to natural persons only:
195
+
196
+ ```json
197
+ "site": {
198
+ "cyLoginPolicies": ["naturalPerson"]
199
+ }
200
+ ```
201
+
202
+ ##### Notes
203
+
204
+ - This configuration applies globally to the service.
205
+ - Both `requireAuth` and `cyLoginPolicy` middlewares must be present on protected routes (automatically included by the default route setup).
206
+
207
+
152
208
  ### 🧩 Dynamic Services
153
209
  Services are rendered dynamically using JSON templates stored in the `/data` folder. All the service configuration, pages, routes, and logic is stored in the JSON files. The service will load `data/:siteId.json` to get the form data when a user visits `/:siteId/:pageUrl`. Checkout the [express-service-shema.json](express-service-shema.json) and the example JSON structure of the **[test.json](data/test.json)** file for more details.
154
210
 
@@ -158,6 +214,7 @@ Here is an example JSON config:
158
214
  {
159
215
  "site": {
160
216
  "id": "test",
217
+ "cyLoginPolicies": ["naturalPerson"], //<-- Allowed CY Login policies
161
218
  "lang": "el", //<-- Default language
162
219
  "languages": [ //<-- Supported languages
163
220
  {
@@ -716,6 +773,8 @@ Here is an example JSON config:
716
773
  Here are some details explaining the JSON structure:
717
774
 
718
775
  - `site` object: Contains information about the site, including the site ID, language, and footer links. See [govcy-frontend-renderer](https://github.com/gov-cy/govcy-frontend-renderer/tree/main#site-and-page-meta-data-explained) for more details. Some fields that are only specific to the govcy-express-forms project are the following:
776
+ - `usesDSFSubmissionPlatform`: A boolean that indicates whether the service uses the DSF submission platform (transforms submission data as needed)
777
+ - `cyLoginPolicies`: which types of authenticated CY Login profiles are allowed to access the service
719
778
  - `submissionDataVersion` : The submission data version,
720
779
  - `rendererVersion` : The govcy-frontend-renderer version,
721
780
  - `designSystemsVersion` : The govcy-design-system version,
@@ -2604,227 +2663,8 @@ To help back-end systems recognize the field as a file, the field's element name
2604
2663
  If these endpoints are not defined in the service JSON, the file upload logic is skipped entirely.
2605
2664
  Existing services will continue to work without modification.
2606
2665
 
2607
- ### ✨ Custom pages feature
2608
- The **Custom pages** feature allows developers to add service-specific custom pages that exist outside the standard Express Services JSON configuration.
2609
-
2610
- These pages can collect data, display conditional content, and inject custom sections into the **review** and **email** stages of the service flow.
2611
-
2612
- #### What do custom pages get out of the framework
2613
- With the custom pages feature, developers can define **pages** and **routes** on an express **service**. On these pages the following apply out of the box:
2614
- - Login requirement
2615
- - User policy requirement
2616
- - Any service eligibility checks
2617
- - Csrf protection on POST
2618
- - Generic error page on errors
2619
-
2620
- The developers can also choose to:
2621
- - store values in the session to be submitted via the submission API
2622
- - define where and what the users sees in the `review` page
2623
- - define errors regarding the custom page in the `review` page
2624
- - define what the user receives the `email`
2625
-
2626
- #### What the framework expects from custom pages
2627
-
2628
- - Every custom page **must be defined** at startup using `defineCustomPages(app, siteId, pageUrl, ...)`.
2629
- - Custom pages are **not** automatically discovered, they must be registered explicitly.
2630
- - Each page must specify on **start up**:
2631
- - `pageTitle`: multilingual object for display in review and email
2632
- - `insertAfterPageUrl`: the page **after which** this custom page will appear
2633
- - `summaryElements`, `errors`, and `email` are managed dynamically in session
2634
- - **NOTE**: Usually a default `required` error must be defined on start up. This will ensure that the when the users try to submit in the “Check your answers” review page, they get an error message to complete an action.
2635
- - `extraProps` extra property stored in the session to be used by the developers as they wish
2636
- - During **runtime**, developers are responsible for setting:
2637
- - `data`: data to include in submission, using the `setCustomPageData` function
2638
- - `summaryElements`: what appears in the “Check your answers” review page, using the `setCustomPageSummaryElements` function
2639
- - `email`: array of [`dsf-email-templates`](https://github.com/gov-cy/dsf-email-templates) components for the submission confirmation email, using the `setCustomPageEmail`
2640
- - `errors`: errors that appear in the “Check your answers” review page. Developers can use the `addCustomPageError` function to add an error, or the `clearCustomPageErrors` to clear the errors
2641
- #### Available methods
2642
-
2643
- | Method | Purpose |
2644
- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
2645
- | `defineCustomPages(store, siteId, pageUrl, pageTitle, insertAfterSummary, insertAfterPageUrl, errors, summaryElements, summaryHtml, extraProps)` | Registers a custom page definition in `app`. Must be called once at startup. |
2646
- | `getCustomPageDefinition(store, siteId, pageUrl)` | Retrieves the custom page definition for a given siteId and pageUrl |
2647
- | `resetCustomPages(configStore, store, siteId)` | Resets per-session data from the global definitions. |
2648
- | `setCustomPageData(store, siteId, pageUrl, dataObject)` | Sets or replaces the data object used during submission. |
2649
- | `setCustomPageSummaryElements(store, siteId, pageUrl, summaryElements)` | Sets what appears in the review page summary. |
2650
- | `clearCustomPageErrors(store, siteId, pageUrl)` | Clears validation errors. |
2651
- | `addCustomPageError(store, siteId, pageUrl, errorId, errorTextObject)` | Adds a validation error. |
2652
- | `setCustomPageEmail(store, siteId, pageUrl, arrayOfEmailObjects)` | Defines email sections for the confirmation email using `dsf-email-templates` components. |
2653
- | `setCustomPageProperty(store, siteId, pageUrl, property, value, isDefinition = false)` | Sets a custom property on a given custom page definition or instance |
2654
- | `getCustomPageProperty(store, siteId, pageUrl, property, isDefinition = false)` | Gets a custom property from a given custom page definition or instance. |
2655
-
2656
- ---
2657
- ##### Example
2658
-
2659
- Below is a practical example of a custom **Declarations** page added to an existing service.
2660
-
2661
- ```js
2662
- import initializeGovCyExpressService from '@gov-cy/govcy-express-services';
2663
- import {
2664
- defineCustomPages, resetCustomPages,
2665
- clearCustomPageErrors, addCustomPageError,
2666
- setCustomPageData, setCustomPageSummaryElements,
2667
- setCustomPageEmail, getCustomPageProperty, setCustomPageProperty
2668
- } from '@gov-cy/govcy-express-services/customPages';
2669
-
2670
- // Initialize the service
2671
- const service = initializeGovCyExpressService({
2672
- beforeMount({ siteRoute, app }) {
2673
-
2674
- // ==========================================================
2675
- // 1️⃣ DEFINE GLOBAL CUSTOM PAGE CONFIGS (once per app)
2676
- // ==========================================================
2677
- defineCustomPages(
2678
- app, // <-- using app as store (global config)
2679
- "cso", // siteId
2680
- "/cso/custom", // pageUrl
2681
- { en: "My custom section", el: "Προσαρμοσμένη ενότητα" }, // pageTitle
2682
- "qualifications", // insertAfterPageUrl
2683
- [ // errors
2684
- {
2685
- id: "custom-error",
2686
- text: {
2687
- en: "This is a custom error custom",
2688
- el: "Αυτή ειναι ενα προσαρμοσμένη σφαλμα custom",
2689
- }
2690
- }
2691
- ],
2692
- [ // summaryElements
2693
- {
2694
- key:
2695
- {
2696
- en: "Extra value",
2697
- el: "Πρόσθετη τιμή"
2698
- },
2699
- value: []
2700
- }
2701
- ],
2702
- false,
2703
- { // other custom properties if needed like nextPage or initial data
2704
- nextPage: "/cso/memberships", // custom property nextPage. Not needed by Express but useful for the custom logic
2705
- data : // custom initial data. Useful when you need the data model to be standard or pre-populated
2706
- {
2707
- something: "",
2708
- }
2709
- }
2710
- );
2711
-
2712
- // ==========================================================
2713
- // 2️⃣ MIDDLEWARE: SET UP SESSION DATA FROM GLOBAL DEFINITIONS
2714
- // ==========================================================
2715
- app.use((req, res, next) => {
2716
- // Initialize session data for custom pages
2717
- req.session.siteData ??= {};
2718
- req.session.siteData["cso"] ??= {};
2719
-
2720
- // Reset session copies if missing (first visit)
2721
- if (!req.session.siteData["cso"].customPages) {
2722
- resetCustomPages(app, req.session, "cso"); // 🔁 deep copy from app to session
2723
- req.session.save((err) => {
2724
- if (err) console.error("⚠️ Error initializing customPages:", err);
2725
- });
2726
- }
2727
-
2728
- next();
2729
- });
2730
-
2731
- // ==========================================================
2732
- // 3️⃣ CUSTOM ROUTES (still per-user)
2733
- // ==========================================================
2734
-
2735
- // GET `/cso/custom`
2736
- siteRoute("cso", "get", "/cso/custom", (req, res) => {
2737
-
2738
- // Render the custom page using the session data
2739
- // It is important for POST to add the csrfToken and to pass on the route query parameter
2740
- res.send(`<form action="/cso/custom${(req.query.route === "review")?"?route=review":""}" method="post">
2741
- custom for ${req.params.siteId}
2742
- <input type="hidden" name="_csrf" value="${req.csrfToken()}">
2743
- <button type="submit">Submit custom page</button>
2744
- </form>`);
2745
-
2746
- });
2747
-
2748
- // POST `/cso/custom`
2749
- siteRoute("cso", "post", "/cso/custom", (req, res) => {
2750
-
2751
- // Update custom page `data` dynamically`
2752
- setCustomPageData(req.session, "cso", "/cso/custom", {
2753
- something: "123",
2754
- });
2755
-
2756
- // Update `summary elements` dynamically (example)
2757
- setCustomPageSummaryElements(req.session, "cso", "/cso/custom",
2758
- [
2759
- {
2760
- key: {
2761
- en: "Extra value",
2762
- el: "Πρόσθετη τιμή"
2763
- },
2764
- value: [
2765
- {
2766
- element: "textElement",
2767
- params: {
2768
- text: {
2769
- en: "123 Changed",
2770
- el: "123 Αλλάχθηκε"
2771
- },
2772
- type: "span",
2773
- showNewLine: true,
2774
- }
2775
- }
2776
- ]
2777
- }
2778
- ]);
2779
-
2780
- // Update the custom page `email`
2781
- setCustomPageEmail(req.session, "cso", "/cso/custom", [
2782
- {
2783
- component: "bodyKeyValue",
2784
- params: {
2785
- type: "ul",
2786
- items: [
2787
- { key: "Extra value", value: "123" },
2788
- { key: "Priority level", value: "High" },
2789
- ],
2790
- },
2791
- }
2792
- ]);
2793
-
2794
- // clear any previous errors
2795
- clearCustomPageErrors(req.session, "cso", "/cso/custom");
2796
-
2797
- // Add a custom error
2798
- // addCustomPageError(req.session, "cso", "/cso/custom", {
2799
- // id: "custom-error",
2800
- // text: {
2801
- // en: "This is a custom error custom",
2802
- // el: "Αυτή ειναι ενα προσαρμοσμένη σφαλμα custom",
2803
- // }
2804
- // });
2805
-
2806
- //if route is review, redirect to review page else go to nextPage property
2807
- if (req.query.route === "review") {
2808
- res.redirect("/cso/review");
2809
- return;
2810
- } else {
2811
- //example of using custom property nextPage
2812
- res.redirect(getCustomPageProperty(req.session, "cso", "/cso/custom", "nextPage", false));
2813
- return;
2814
- }
2815
- });
2816
-
2817
-
2818
- // ==========================================================
2819
- },
2820
- });
2821
-
2822
-
2823
- // Start the server
2824
- service.startServer();
2825
-
2826
- ```
2827
-
2666
+ ### ✨ Custom pages feature
2667
+ The **Custom pages** feature allows developers to code service-specific custom pages that exist outside the standard Express Services JSON configuration. More details at [Custom-pages.md](docs/Custom-pages.md)
2828
2668
 
2829
2669
  ### 🛣️ Routes
2830
2670
  The project uses express.js to serve the following routes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gov-cy/govcy-express-services",
3
- "version": "1.4.3",
3
+ "version": "1.6.0",
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",
@@ -56,7 +56,7 @@
56
56
  "coverage:badge": "coverage-badges --output ./coverage-badges.svg && npm run coverage:copy"
57
57
  },
58
58
  "dependencies": {
59
- "@gov-cy/dsf-email-templates": "^2.1.1",
59
+ "@gov-cy/dsf-email-templates": "^2.1.11",
60
60
  "@gov-cy/govcy-frontend-renderer": "^1.26.0",
61
61
  "axios": "^1.9.0",
62
62
  "cookie-parser": "^1.4.7",
package/src/index.mjs CHANGED
@@ -18,7 +18,7 @@ import { govcyCsrfMiddleware } from './middleware/govcyCsrf.mjs';
18
18
  import { govcySessionData } from './middleware/govcySessionData.mjs';
19
19
  import { govcyHttpErrorHandler } from './middleware/govcyHttpErrorHandler.mjs';
20
20
  import { govcyLanguageMiddleware } from './middleware/govcyLanguageMiddleware.mjs';
21
- import { requireAuth, naturalPersonPolicy, handleLoginRoute, handleSigninOidc, handleLogout } from './middleware/cyLoginAuth.mjs';
21
+ import { requireAuth, cyLoginPolicy, handleLoginRoute, handleSigninOidc, handleLogout } from './middleware/cyLoginAuth.mjs';
22
22
  import { serviceConfigDataMiddleware } from './middleware/govcyConfigSiteData.mjs';
23
23
  import { govcyManifestHandler } from './middleware/govcyManifestHandler.mjs';
24
24
  import { govcyRoutePageHandler } from './middleware/govcyRoutePageHandler.mjs';
@@ -103,7 +103,7 @@ export default function initializeGovCyExpressService(opts = {}) {
103
103
  // 🛠️ Debugging routes -----------------------------------------------------
104
104
  // 🙍🏻‍♂️ -- ROUTE: Debugging route Protected Route
105
105
  // if (!isProdOrStaging()) {
106
- // app.get('/user', requireAuth, naturalPersonPolicy, (req, res) => {
106
+ // app.get('/user', requireAuth, cyLoginPolicy, (req, res) => {
107
107
  // res.send(`
108
108
  // User name: ${req.session.user.name}
109
109
  // <br> Sub: ${req.session.user.sub}
@@ -139,7 +139,7 @@ export default function initializeGovCyExpressService(opts = {}) {
139
139
  app.post('/apis/:siteId/:pageUrl/upload',
140
140
  serviceConfigDataMiddleware,
141
141
  requireAuth, // UNCOMMENT
142
- naturalPersonPolicy, // UNCOMMENT
142
+ cyLoginPolicy, // UNCOMMENT
143
143
  govcyServiceEligibilityHandler(true), // UNCOMMENT
144
144
  govcyFileUpload);
145
145
 
@@ -147,7 +147,7 @@ export default function initializeGovCyExpressService(opts = {}) {
147
147
  app.post('/apis/:siteId/:pageUrl/multiple/add/upload',
148
148
  serviceConfigDataMiddleware,
149
149
  requireAuth, // UNCOMMENT
150
- naturalPersonPolicy, // UNCOMMENT
150
+ cyLoginPolicy, // UNCOMMENT
151
151
  govcyServiceEligibilityHandler(true), // UNCOMMENT
152
152
  govcyFileUpload
153
153
  );
@@ -176,7 +176,7 @@ export default function initializeGovCyExpressService(opts = {}) {
176
176
  injectSiteId,
177
177
  serviceConfigDataMiddleware,
178
178
  requireAuth,
179
- naturalPersonPolicy,
179
+ cyLoginPolicy,
180
180
  govcyServiceEligibilityHandler(),
181
181
  ...handlers,
182
182
  ];
@@ -196,7 +196,7 @@ export default function initializeGovCyExpressService(opts = {}) {
196
196
  app.post('/apis/:siteId/:pageUrl/multiple/edit/:index/upload',
197
197
  serviceConfigDataMiddleware,
198
198
  requireAuth, // UNCOMMENT
199
- naturalPersonPolicy, // UNCOMMENT
199
+ cyLoginPolicy, // UNCOMMENT
200
200
  govcyServiceEligibilityHandler(true), // UNCOMMENT
201
201
  govcyFileUpload
202
202
  );
@@ -205,7 +205,7 @@ export default function initializeGovCyExpressService(opts = {}) {
205
205
  app.get('/:siteId/:pageUrl/multiple/add/view-file/:elementName',
206
206
  serviceConfigDataMiddleware,
207
207
  requireAuth,
208
- naturalPersonPolicy,
208
+ cyLoginPolicy,
209
209
  govcyServiceEligibilityHandler(true),
210
210
  govcyFileViewHandler());
211
211
 
@@ -213,7 +213,7 @@ export default function initializeGovCyExpressService(opts = {}) {
213
213
  app.get('/:siteId/:pageUrl/multiple/add/delete-file/:elementName',
214
214
  serviceConfigDataMiddleware,
215
215
  requireAuth,
216
- naturalPersonPolicy,
216
+ cyLoginPolicy,
217
217
  govcyServiceEligibilityHandler(),
218
218
  govcyLoadSubmissionData(),
219
219
  govcyFileDeletePageHandler(),
@@ -224,7 +224,7 @@ export default function initializeGovCyExpressService(opts = {}) {
224
224
  app.get('/:siteId/:pageUrl/multiple/edit/:index/delete-file/:elementName',
225
225
  serviceConfigDataMiddleware,
226
226
  requireAuth,
227
- naturalPersonPolicy,
227
+ cyLoginPolicy,
228
228
  govcyServiceEligibilityHandler(),
229
229
  govcyLoadSubmissionData(),
230
230
  govcyFileDeletePageHandler(),
@@ -236,7 +236,7 @@ export default function initializeGovCyExpressService(opts = {}) {
236
236
  app.post('/:siteId/:pageUrl/multiple/add/delete-file/:elementName',
237
237
  serviceConfigDataMiddleware,
238
238
  requireAuth,
239
- naturalPersonPolicy,
239
+ cyLoginPolicy,
240
240
  govcyServiceEligibilityHandler(true),
241
241
  govcyFileDeletePostHandler()
242
242
  );
@@ -245,7 +245,7 @@ export default function initializeGovCyExpressService(opts = {}) {
245
245
  app.post('/:siteId/:pageUrl/multiple/edit/:index/delete-file/:elementName',
246
246
  serviceConfigDataMiddleware,
247
247
  requireAuth,
248
- naturalPersonPolicy,
248
+ cyLoginPolicy,
249
249
  govcyServiceEligibilityHandler(true),
250
250
  govcyFileDeletePostHandler()
251
251
  );
@@ -255,33 +255,33 @@ export default function initializeGovCyExpressService(opts = {}) {
255
255
  app.get('/:siteId/:pageUrl/multiple/edit/:index/view-file/:elementName',
256
256
  serviceConfigDataMiddleware,
257
257
  requireAuth,
258
- naturalPersonPolicy,
258
+ cyLoginPolicy,
259
259
  govcyServiceEligibilityHandler(true),
260
260
  govcyFileViewHandler());
261
261
 
262
262
  // 🏠 -- ROUTE: Handle route with only siteId (/:siteId or /:siteId/)
263
- app.get('/:siteId', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(true), govcyLoadSubmissionData(), govcyPageHandler(), renderGovcyPage());
263
+ app.get('/:siteId', serviceConfigDataMiddleware, requireAuth, cyLoginPolicy, govcyServiceEligibilityHandler(true), govcyLoadSubmissionData(), govcyPageHandler(), renderGovcyPage());
264
264
 
265
265
  // 👀 -- ROUTE: Add Review Page Route (BEFORE the dynamic route)
266
- app.get('/:siteId/review', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(), govcyLoadSubmissionData(), govcyReviewPageHandler(), renderGovcyPage());
266
+ app.get('/:siteId/review', serviceConfigDataMiddleware, requireAuth, cyLoginPolicy, govcyServiceEligibilityHandler(), govcyLoadSubmissionData(), govcyReviewPageHandler(), renderGovcyPage());
267
267
 
268
268
  // ✅📄 -- ROUTE: Add Success PDF Route (BEFORE the dynamic route)
269
- app.get('/:siteId/success/pdf', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(), govcySuccessPageHandler(true), govcyPDFRender());
269
+ app.get('/:siteId/success/pdf', serviceConfigDataMiddleware, requireAuth, cyLoginPolicy, govcyServiceEligibilityHandler(), govcySuccessPageHandler(true), govcyPDFRender());
270
270
 
271
271
  // ✅ -- ROUTE: Add Success Page Route (BEFORE the dynamic route)
272
- app.get('/:siteId/success', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(), govcySuccessPageHandler(), renderGovcyPage());
272
+ app.get('/:siteId/success', serviceConfigDataMiddleware, requireAuth, cyLoginPolicy, govcyServiceEligibilityHandler(), govcySuccessPageHandler(), renderGovcyPage());
273
273
 
274
274
  // 👀🗃️ -- ROUTE: View file (BEFORE the dynamic route)
275
- app.get('/:siteId/:pageUrl/view-file/:elementName', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(), govcyLoadSubmissionData(), govcyFileViewHandler());
275
+ app.get('/:siteId/:pageUrl/view-file/:elementName', serviceConfigDataMiddleware, requireAuth, cyLoginPolicy, govcyServiceEligibilityHandler(), govcyLoadSubmissionData(), govcyFileViewHandler());
276
276
 
277
277
  // ❌🗃️ -- ROUTE: Delete file (BEFORE the dynamic route)
278
- app.get('/:siteId/:pageUrl/delete-file/:elementName', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(), govcyLoadSubmissionData(), govcyFileDeletePageHandler(), renderGovcyPage());
278
+ app.get('/:siteId/:pageUrl/delete-file/:elementName', serviceConfigDataMiddleware, requireAuth, cyLoginPolicy, govcyServiceEligibilityHandler(), govcyLoadSubmissionData(), govcyFileDeletePageHandler(), renderGovcyPage());
279
279
 
280
280
  // ➕ -- ROUTE: Add item page (BEFORE the generic dynamic route)
281
281
  app.get('/:siteId/:pageUrl/multiple/add',
282
282
  serviceConfigDataMiddleware,
283
283
  requireAuth,
284
- naturalPersonPolicy,
284
+ cyLoginPolicy,
285
285
  govcyServiceEligibilityHandler(true),
286
286
  govcyLoadSubmissionData(),
287
287
  govcyMultipleThingsAddHandler(),
@@ -293,7 +293,7 @@ export default function initializeGovCyExpressService(opts = {}) {
293
293
  app.post('/:siteId/:pageUrl/multiple/add',
294
294
  serviceConfigDataMiddleware,
295
295
  requireAuth,
296
- naturalPersonPolicy,
296
+ cyLoginPolicy,
297
297
  govcyServiceEligibilityHandler(true),
298
298
  govcyMultipleThingsAddPostHandler()
299
299
  );
@@ -302,7 +302,7 @@ export default function initializeGovCyExpressService(opts = {}) {
302
302
  app.get('/:siteId/:pageUrl/multiple/edit/:index',
303
303
  serviceConfigDataMiddleware,
304
304
  requireAuth,
305
- naturalPersonPolicy,
305
+ cyLoginPolicy,
306
306
  govcyServiceEligibilityHandler(true),
307
307
  govcyLoadSubmissionData(),
308
308
  govcyMultipleThingsEditHandler(),
@@ -313,7 +313,7 @@ export default function initializeGovCyExpressService(opts = {}) {
313
313
  app.post('/:siteId/:pageUrl/multiple/edit/:index',
314
314
  serviceConfigDataMiddleware,
315
315
  requireAuth,
316
- naturalPersonPolicy,
316
+ cyLoginPolicy,
317
317
  govcyServiceEligibilityHandler(true),
318
318
  govcyMultipleThingsEditPostHandler()
319
319
  );
@@ -322,7 +322,7 @@ export default function initializeGovCyExpressService(opts = {}) {
322
322
  app.get('/:siteId/:pageUrl/multiple/delete/:index',
323
323
  serviceConfigDataMiddleware,
324
324
  requireAuth,
325
- naturalPersonPolicy,
325
+ cyLoginPolicy,
326
326
  govcyServiceEligibilityHandler(),
327
327
  govcyLoadSubmissionData(),
328
328
  govcyMultipleThingsDeletePageHandler(),
@@ -333,7 +333,7 @@ export default function initializeGovCyExpressService(opts = {}) {
333
333
  app.post('/:siteId/:pageUrl/multiple/delete/:index',
334
334
  serviceConfigDataMiddleware,
335
335
  requireAuth,
336
- naturalPersonPolicy,
336
+ cyLoginPolicy,
337
337
  govcyServiceEligibilityHandler(true),
338
338
  govcyMultipleThingsDeletePostHandler()
339
339
  );
@@ -344,22 +344,22 @@ export default function initializeGovCyExpressService(opts = {}) {
344
344
  app.post('/:siteId/:pageUrl/update-my-details-response',
345
345
  serviceConfigDataMiddleware,
346
346
  requireAuth,
347
- naturalPersonPolicy,
347
+ cyLoginPolicy,
348
348
  govcyServiceEligibilityHandler(true),
349
349
  govcyUpdateMyDetailsPostHandler());
350
350
  // ----- `updateMyDetails` handling
351
351
 
352
352
  // 📝 -- ROUTE: Dynamic route to render pages based on siteId and pageUrl, using govcyPageHandler middleware
353
- app.get('/:siteId/:pageUrl', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(true), govcyLoadSubmissionData(), govcyPageHandler(), renderGovcyPage());
353
+ app.get('/:siteId/:pageUrl', serviceConfigDataMiddleware, requireAuth, cyLoginPolicy, govcyServiceEligibilityHandler(true), govcyLoadSubmissionData(), govcyPageHandler(), renderGovcyPage());
354
354
 
355
355
  // ❌🗃️📥 -- ROUTE: Handle POST requests for delete file
356
- app.post('/:siteId/:pageUrl/delete-file/:elementName', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(true), govcyFileDeletePostHandler());
356
+ app.post('/:siteId/:pageUrl/delete-file/:elementName', serviceConfigDataMiddleware, requireAuth, cyLoginPolicy, govcyServiceEligibilityHandler(true), govcyFileDeletePostHandler());
357
357
 
358
358
  // 📥 -- ROUTE: Handle POST requests for review page. The `submit` action
359
- app.post('/:siteId/review', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(), govcyReviewPostHandler());
359
+ app.post('/:siteId/review', serviceConfigDataMiddleware, requireAuth, cyLoginPolicy, govcyServiceEligibilityHandler(), govcyReviewPostHandler());
360
360
 
361
361
  // 👀📥 -- ROUTE: Handle POST requests (Form Submissions) based on siteId and pageUrl, using govcyFormsPostHandler middleware
362
- app.post('/:siteId/:pageUrl', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(true), govcyFormsPostHandler());
362
+ app.post('/:siteId/:pageUrl', serviceConfigDataMiddleware, requireAuth, cyLoginPolicy, govcyServiceEligibilityHandler(true), govcyFormsPostHandler());
363
363
 
364
364
  // post for /:siteId/review
365
365
 
@@ -7,10 +7,10 @@
7
7
  import { getLoginUrl, handleCallback, getLogoutUrl } from '../auth/cyLoginAuth.mjs';
8
8
  import { logger } from "../utils/govcyLogger.mjs";
9
9
  import { handleMiddlewareError } from "../utils/govcyUtils.mjs";
10
- import { errorResponse } from "../utils/govcyApiResponse.mjs";
10
+ import { errorResponse } from "../utils/govcyApiResponse.mjs";
11
11
  import { isApiRequest } from '../utils/govcyApiDetection.mjs';
12
12
 
13
- /* c8 ignore start */
13
+
14
14
  /**
15
15
  * Middleware to check if the user is authenticated. If not, redirect to the login page.
16
16
  *
@@ -33,39 +33,7 @@ export function requireAuth(req, res, next) {
33
33
  next();
34
34
  }
35
35
 
36
- /**
37
- * Middleware to enforce natural person policy. If the user is not a natural person, return a 403 error.
38
- *
39
- * @param {object} req The request object
40
- * @param {object} res The response object
41
- * @param {object} next The next middleware function
42
- */
43
- export function naturalPersonPolicy(req, res, next) {
44
- // // allow only natural persons with approved profiles
45
- // if (req.session.user.profile_type == 'Individual' && req.session.user.unique_identifier) {
46
- // next();
47
- // } else {
48
- // return handleMiddlewareError("🚨 Access Denied: natural person policy not met.", 403, next);
49
- // }
50
- // https://dev.azure.com/cyprus-gov-cds/Documentation/_wiki/wikis/Documentation/42/For-Cyprus-Natural-or-Legal-person
51
- const { profile_type, unique_identifier } = req.session.user || {};
52
- // Allow only natural persons with approved profiles
53
- if (profile_type === 'Individual' && unique_identifier) {
54
-
55
- // Validate Cypriot Citizen (starts with "00" and is 10 characters long)
56
- if (unique_identifier.startsWith('00') && unique_identifier.length === 10) {
57
- return next();
58
- }
59
-
60
- // Validate Foreigner with ARN (starts with "05" and is 10 characters long)
61
- if (unique_identifier.startsWith('05') && unique_identifier.length === 10) {
62
- return next();
63
- }
64
- }
65
-
66
- // Deny access if validation fails
67
- return handleMiddlewareError("🚨 Access Denied: natural person policy not met.", 403, next);
68
- }
36
+ /* c8 ignore start */
69
37
 
70
38
  /**
71
39
  * Middleware to handle the login route. Redirects the user to the login URL.
@@ -107,11 +75,11 @@ export function handleSigninOidc() {
107
75
  // Redirect to the stored URL after login or fallback to '/'
108
76
  const redirectUrl = req.session.redirectAfterLogin || '/';
109
77
  // Clean up session for redirect after login
110
- delete req.session.redirectAfterLogin;
78
+ delete req.session.redirectAfterLogin;
111
79
  // Redirect to the stored URL
112
80
  res.redirect(redirectUrl);
113
81
  } catch (error) {
114
- logger.debug('Token exchange failed:', error,req);
82
+ logger.debug('Token exchange failed:', error, req);
115
83
  res.status(500).send('Authentication failed');
116
84
  }
117
85
  }
@@ -138,4 +106,79 @@ export function handleLogout() {
138
106
  });
139
107
  };
140
108
  }
141
- /* c8 ignore end */
109
+ /* c8 ignore end */
110
+
111
+
112
+ /************************************************************************/
113
+ /**
114
+ * Middleware to enforce natural person policy. If the user is not a verified natural person, return a false.
115
+ *
116
+ * @param {object} req The request object
117
+ */
118
+ export function naturalPersonPolicy(req) {
119
+ // https://dev.azure.com/cyprus-gov-cds/Documentation/_wiki/wikis/Documentation/42/For-Cyprus-Natural-or-Legal-person
120
+ const { profile_type, unique_identifier } = req.session.user || {};
121
+ // Allow only natural persons with approved profiles
122
+ if (profile_type === 'Individual' && unique_identifier) {
123
+
124
+ // Validate Cypriot Citizen (starts with "00" and is 10 characters long)
125
+ if (unique_identifier.startsWith('00') && unique_identifier.length === 10) {
126
+ return true;
127
+ }
128
+
129
+ // Validate Foreigner with ARN (starts with "05" and is 10 characters long)
130
+ if (unique_identifier.startsWith('05') && unique_identifier.length === 10) {
131
+ return true;
132
+ }
133
+ }
134
+
135
+ // Deny access if validation fails
136
+ return false;
137
+ }
138
+
139
+ /** * Middleware to enforce legal person policy. If the user is not a verified legal person, return false.
140
+ *
141
+ * @param {object} req The request object
142
+ */
143
+ export function legalPersonPolicy(req) {
144
+ // https://dev.azure.com/cyprus-gov-cds/Documentation/_wiki/wikis/Documentation/42/For-Cyprus-Natural-or-Legal-person
145
+ const { profile_type, legal_unique_identifier } = req.session.user || {};
146
+ // Allow only legal persons with approved profiles
147
+ if (profile_type === 'Organisation' && legal_unique_identifier) {
148
+ return true;
149
+ }
150
+
151
+ // Deny access if validation fails
152
+ return false;
153
+ }
154
+
155
+ const policyRegistry = {
156
+ naturalPerson: naturalPersonPolicy,
157
+ legalPerson: legalPersonPolicy,
158
+ };
159
+
160
+ export function cyLoginPolicy(req, res, next) {
161
+ // Check what is allowed in the service configuration
162
+ const allowed = req?.serviceData?.site?.cyLoginPolicies || ["naturalPerson"];
163
+
164
+ // Check each policy in the allowed list
165
+ for (const name of allowed) {
166
+ const policy = policyRegistry[name];
167
+ // Skip if the policy is not registered
168
+ if (!policy) {
169
+ console.warn(`🚨 Unknown policy: ${name}`);
170
+ continue
171
+ };
172
+
173
+ // 🚨 Strict mode: let errors throw naturally if data is malformed
174
+ const passed = policy(req);
175
+ if (passed) return next();
176
+ }
177
+
178
+ return handleMiddlewareError(
179
+ "🚨 Access Denied: none of the allowed CY Login policies matched.",
180
+ 403,
181
+ next
182
+ );
183
+ }
184
+ /************************************************************************/
@@ -26,6 +26,7 @@ export function govcyPageHandler() {
26
26
  logger.debug(`No pageUrl provided for siteId: ${siteId}`, req);
27
27
  // Example: Redirect to a default page or load a homepage
28
28
  pageUrl = "index"; // Change "index" to whatever makes sense for your service
29
+ req.params.pageUrl = pageUrl; // Update req.params to reflect the new pageUrl
29
30
  }
30
31
 
31
32
  // 🔍 Find the page by pageUrl
@@ -184,7 +184,7 @@ export function govcyReviewPostHandler() {
184
184
  const submissionData = prepareSubmissionData(req, siteId, service);
185
185
 
186
186
  // Prepare submission data for API
187
- const submissionDataAPI = prepareSubmissionDataAPI(submissionData);
187
+ const submissionDataAPI = prepareSubmissionDataAPI(submissionData, service);
188
188
 
189
189
  logger.debug("Prepared submission data for API:", submissionDataAPI);
190
190
 
@@ -497,7 +497,7 @@ export function govcyUpdateMyDetailsPostHandler() {
497
497
  // 🔄 User chose to update their details externally
498
498
  const redirectUrl = constructUpdateMyDetailsRedirect(req, userId, umdBaseURL, returnUrl);
499
499
  logger.info("User opted to update details externally", {
500
- userId: user.unique_identifier,
500
+ userId: user.sub,
501
501
  redirectUrl
502
502
  });
503
503
  return res.redirect(redirectUrl);
@@ -133,7 +133,7 @@ export function populateFormData(
133
133
  // Delete link (preserve ?route=review if present)
134
134
  element.params.deleteHref = `${basePath}/delete-file/${fieldName}${(routeParam) ? `?route=${routeParam}` : ''}`
135
135
 
136
-
136
+
137
137
  } else {
138
138
  // TODO: Ask Andreas how to handle empty file inputs
139
139
  element.params.value = "";
@@ -295,3 +295,74 @@ export function getFormData(elements, formData, store = {}, siteId = "", pageUrl
295
295
 
296
296
  return filteredData;
297
297
  }
298
+
299
+ /**
300
+ * Get empty form data for multipleThings elements.
301
+ * Used to fill in empty multipleThings pages with the correct structure
302
+ *
303
+ * @param {object|Array} pageOrElements The page or elements of a conditional radio
304
+ * @param {object} emptyObject The object to populate with empty values
305
+ * @returns {object} An object with empty values for each form element
306
+ */
307
+ export function getMultipleThingsEmptyFormData(pageOrElements, emptyObject = {}) {
308
+ // Determine if we're given a full page or just an array of elements
309
+ let elements = [];
310
+ if (Array.isArray(pageOrElements)) {
311
+ elements = pageOrElements; // recursion case
312
+ } else if (pageOrElements?.pageTemplate?.sections) {
313
+ // Deep copy to avoid modifying the original
314
+ const pageTemplateCopy = JSON.parse(JSON.stringify(pageOrElements.pageTemplate));
315
+ // Find the first form element in sections
316
+ for (const section of pageTemplateCopy.sections) {
317
+ const form = section.elements?.find(el => el.element === "form");
318
+ if (form) {
319
+ elements = form?.params?.elements || [];
320
+ break;
321
+ }
322
+ }
323
+ } else {
324
+ // No valid elements
325
+ return emptyObject;
326
+ }
327
+
328
+ // Iterate through elements in order (like getFormData)
329
+ elements.forEach(element => {
330
+ const { name } = element.params || {};
331
+
332
+ if (ALLOWED_FORM_ELEMENTS.includes(element.element) && name) {
333
+ // Handle different element types
334
+ if (["checkboxes", "radios", "select"].includes(element.element)) {
335
+ emptyObject[name] = "";
336
+
337
+ // Handle conditional radios (same recursion pattern as getFormData)
338
+ if (element.element === "radios" && Array.isArray(element.params?.items)) {
339
+ element.params.items.forEach(item => {
340
+ if (item.conditionalElements) {
341
+ Object.assign(
342
+ emptyObject,
343
+ getMultipleThingsEmptyFormData(item.conditionalElements, {})
344
+ );
345
+ }
346
+ });
347
+ }
348
+ }
349
+
350
+ // Handle dateInput (single name, per your clarification)
351
+ else if (element.element === "dateInput") {
352
+ emptyObject[name] = "";
353
+ }
354
+
355
+ // Handle fileInput (suffix Attachment, per submission schema)
356
+ else if (element.element === "fileInput") {
357
+ emptyObject[`${name}Attachment`] = "";
358
+ }
359
+
360
+ // Handle all other elements (textInput, textArea, datePicker, etc.)
361
+ else {
362
+ emptyObject[name] = "";
363
+ }
364
+ }
365
+ });
366
+
367
+ return emptyObject;
368
+ }
@@ -4,7 +4,8 @@ import * as dataLayer from "./govcyDataLayer.mjs";
4
4
  import { DSFEmailRenderer } from '@gov-cy/dsf-email-templates';
5
5
  import { ALLOWED_FORM_ELEMENTS } from "./govcyConstants.mjs";
6
6
  import { evaluatePageConditions } from "./govcyExpressions.mjs";
7
- import { getPageConfigData } from "./govcyLoadConfigData.mjs";
7
+ import { getServiceConfigData, getPageConfigData } from "./govcyLoadConfigData.mjs";
8
+ import { getMultipleThingsEmptyFormData } from "./govcyFormHandling.mjs";
8
9
  import { logger } from "./govcyLogger.mjs";
9
10
  import { createUmdManualPageTemplate } from "../middleware/govcyUpdateMyDetails.mjs"
10
11
  import nunjucks from "nunjucks";
@@ -252,21 +253,48 @@ export function prepareSubmissionData(req, siteId, service) {
252
253
  /**
253
254
  * Prepares the submission data for the API, stringifying all relevant fields.
254
255
  *
255
- * @param {object} data data prepared by `prepareSubmissionData`
256
+ * @param {object} data data prepared by `prepareSubmissionData`\
257
+ * @param {object} service service config data
256
258
  * @returns {object} The API-ready submission data object with all fields as strings
257
259
  */
258
- export function prepareSubmissionDataAPI(data) {
260
+ export function prepareSubmissionDataAPI(data, service) {
261
+
262
+ //deep copy data to avoid mutating the original
263
+ let dataObj = JSON.parse(JSON.stringify(data));
264
+ // get site?.submissionAPIEndpoint.isDSFSubmissionPlatform
265
+ const isDSFSubmissionPlatform = service?.site?.usesDSFSubmissionPlatform || false;
266
+
267
+ // If DSF Submission Platform, ensure submissionData is an array
268
+ // this is intended for multipleThings pages only
269
+ if (isDSFSubmissionPlatform) {
270
+ // loop through submissionData
271
+ for (const [key, value] of Object.entries(dataObj.submissionData || {})) {
272
+ // check if the value is an empty array
273
+ if (Array.isArray(value) && value.length === 0) {
274
+ // get the pageConfigData for the page
275
+ try {
276
+ const page = getPageConfigData(service, key);
277
+ let pageEmptyData = getMultipleThingsEmptyFormData(page);
278
+ //replace the dataObj.submissionData[key] with the empty data
279
+ dataObj.submissionData[key] = [pageEmptyData];
280
+
281
+ } catch (error) {
282
+ logger.error('Error getting pageConfigData:', key);
283
+ }
284
+ }
285
+ }
286
+ }
259
287
 
260
288
  return {
261
- submissionUsername: String(data.submissionUsername ?? ""),
262
- submissionEmail: String(data.submissionEmail ?? ""),
263
- submissionData: JSON.stringify(data.submissionData ?? {}),
264
- submissionDataVersion: String(data.submissionDataVersion ?? ""),
265
- printFriendlyData: JSON.stringify(data.printFriendlyData ?? []),
266
- rendererData: JSON.stringify(data.rendererData ?? {}),
267
- rendererVersion: String(data.rendererVersion ?? ""),
268
- designSystemsVersion: String(data.designSystemsVersion ?? ""),
269
- service: JSON.stringify(data.service ?? {})
289
+ submissionUsername: String(dataObj.submissionUsername ?? ""),
290
+ submissionEmail: String(dataObj.submissionEmail ?? ""),
291
+ submissionData: JSON.stringify(dataObj.submissionData ?? {}),
292
+ submissionDataVersion: String(dataObj.submissionDataVersion ?? ""),
293
+ printFriendlyData: JSON.stringify(dataObj.printFriendlyData ?? []),
294
+ rendererData: JSON.stringify(dataObj.rendererData ?? {}),
295
+ rendererVersion: String(dataObj.rendererVersion ?? ""),
296
+ designSystemsVersion: String(dataObj.designSystemsVersion ?? ""),
297
+ service: JSON.stringify(dataObj.service ?? {})
270
298
  };
271
299
  }
272
300
 
@@ -28,6 +28,7 @@ function validateValue(value, rules) {
28
28
  alpha: (val) => /^[A-Za-zΑ-Ωα-ω\u0370-\u03ff\u1f00-\u1fff\s]+$/.test(val),
29
29
  alphaNum: (val) => /^[A-Za-zΑ-Ωα-ω\u0370-\u03ff\u1f00-\u1fff0-9\s]+$/.test(val),
30
30
  noSpecialChars: (val) => /^([0-9]|[A-Z]|[a-z]|[α-ω]|[Α-Ω]|[,]|[.]|[-]|[(]|[)]|[?]|[!]|[;]|[:]|[\n]|[\r]|[ _]|[\u0370-\u03ff\u1f00-\u1fff])+$/.test(val),
31
+ noSpecialCharsEl: (val) => /^([0-9]|[α-ω]|[Α-Ω]|[,]|[.]|[-]|[(]|[)]|[?]|[!]|[;]|[:]|[\n]|[\r]|[ _]|[\u0370-\u03ff\u1f00-\u1fff])+$/.test(val),
31
32
  name: (val) => /^[A-Za-zΑ-Ωα-ω\u0370-\u03ff\u1f00-\u1fff\s'-]+$/.test(val),
32
33
  tel: (val) => /^(?:\+|00)?[\d\s\-()]{8,20}$/.test(val.replace(/[\s\-()]/g, '')),
33
34
  mobile: (val) => /^(?:\+|00)?[\d\s\-()]{8,20}$/.test(val.replace(/[\s\-()]/g, '')),