hof 22.7.0-service-paused-beta.13 → 22.7.0-service-unavailable-beta.2

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/CHANGELOG.md CHANGED
@@ -1,9 +1,11 @@
1
- ## 2025-05-14, Version 22.7.0 (Stable), @Rhodine-orleans-lindsay
1
+ ## 2025-05-20, Version 22.7.0 (Stable), @Rhodine-orleans-lindsay @sulthan-ahmed
2
2
  ### Added
3
- - Service paused funtionality that allows for services to redirect to a 'Service Unavailable' when there is a need to pause a service.
4
- - Adds 'service paused' error middleware
5
- - Includes default service paused html view
6
- - Includes flag to set service paused config to true or false in order to enable page
3
+ - 'Service Unavailable' functionality added which allows for services to redirect to a 'Service Unavailable' page when there is a need to pause a service for things like maintenance:
4
+ - Adds 'service unavailable' error middleware
5
+ - Includes default service unavailable html view
6
+ - Includes flag to set `SERVICE_UNAVAILABLE` config to true to enable the functionality
7
+ ### Changed
8
+ - Error pages can now show the service name in the title and journey header
7
9
  ### Security
8
10
  - Updates patch and minor dependencies
9
11
 
package/README.md CHANGED
@@ -805,7 +805,7 @@ const pdfData = await pdfModel.save();
805
805
 
806
806
  # HOF Middleware
807
807
 
808
- A collection of commonly used HOF middleware, exports `cookies`, `notFound`, `servicePaused` and `errors` on `middleware`
808
+ A collection of commonly used HOF middleware, exports `cookies`, `notFound`, `serviceUnavailable` and `errors` on `middleware`
809
809
 
810
810
  ## Arranging the middleware in your app
811
811
 
@@ -844,18 +844,18 @@ You can also provide an array of healthcheck URLs with `healthcheckUrls`,
844
844
  should you not want to throw a Cookies required error when requesting the app with specific URLs.
845
845
  Kubernetes healthcheck URLs are provided as defaults if no overrides are supplied.
846
846
 
847
- ## Service Unavailable (Service Paused)
848
- Allows a service to be paused when required and then resumed. It ensures that anyone using the service that lands on any part of the form is diverted to a "Service Unavailable" page which will communicate to the user that the service is not available at this time.
847
+ ## Service Unavailable
848
+ Allows a service to be paused when required and then resumed. It ensures that anyone using the service that lands on any part of the form is diverted to a "Service Unavailable" page which will communicate to the user that the service is not available at this time.
849
849
 
850
850
  ### Usage
851
- - Set the `SERVICE_PAUSED` env to `true` in your service.
851
+ - Set the `SERVICE_UNAVAILABLE` env to `true` in your service.
852
852
 
853
853
  ### Page Content Customisation
854
854
  There is default text for this page. Default text can be overridden by setting the `message` and `answers-saved` properties in the `errors.json` file of the service. Note that information relating to who to contact and alternatives to using the form is optional and so there is no default text for these unless the properties `contact` and `alternative` are set in errors.json:
855
855
 
856
856
  ```json
857
857
  {
858
- "service-paused" : {
858
+ "service-unavailable" : {
859
859
  "message": "This service will be unavailble for a week.",
860
860
  "answers-saved": "Your answers have not been saved.",
861
861
  "contact": "You can contact test@test.com for more information",
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
  /* eslint no-process-env: "off" */
3
3
  const rateLimits = require('./rate-limits');
4
+ const Helper = require('../utilities/helpers');
5
+ const parseBoolean = Helper.getEnvBoolean;
4
6
 
5
7
  const defaults = {
6
8
  appName: process.env.APP_NAME || 'HOF Application',
@@ -9,7 +11,7 @@ const defaults = {
9
11
  translations: 'translations',
10
12
  start: true,
11
13
  csp: {
12
- disabled: process.env.DISABLE_CSP === 'true'
14
+ disabled: parseBoolean(process.env.DISABLE_CSP, false, 'DISABLE_CSP')
13
15
  },
14
16
  getCookies: true,
15
17
  getTerms: true,
@@ -52,7 +54,7 @@ const defaults = {
52
54
  },
53
55
  serveStatic: process.env.SERVE_STATIC_FILES !== 'false',
54
56
  sessionTimeOutWarning: process.env.SESSION_TIMEOUT_WARNING || 300,
55
- servicePaused: process.env.SERVICE_PAUSED || false
57
+ serviceUnavailable: parseBoolean(process.env.SERVICE_UNAVAILABLE, false, 'SERVICE_UNAVAILABLE')
56
58
  };
57
59
 
58
60
  module.exports = Object.assign({}, defaults, rateLimits);
@@ -27,7 +27,7 @@
27
27
  "pre-time-to-wait": "Please try again in ",
28
28
  "post-time-to-wait": " minutes."
29
29
  },
30
- "service-paused": {
30
+ "service-unavailable": {
31
31
  "title": "Sorry, the service is unavailable",
32
32
  "message": "This service is temporarily unavailable.",
33
33
  "answers-saved": "Your answers have not been saved."
@@ -0,0 +1,13 @@
1
+ {{<error}}
2
+ {{$content}}
3
+ <h1 class="govuk-heading-l">{{title}}</h1>
4
+ <p class="govuk-body">{{message}}</p>
5
+ <p class="govuk-body">{{answers-saved}}</p>
6
+ {{#contact}}
7
+ <p class="govuk-body">{{contact}}</p>
8
+ {{/contact}}
9
+ {{#alternative}}
10
+ <p class="govuk-body">{{alternative}}</p>
11
+ {{/alternative}}
12
+ {{/content}}
13
+ {{/error}}
package/index.js CHANGED
@@ -51,8 +51,8 @@ const loadRoutes = (app, config) => {
51
51
  };
52
52
 
53
53
  const applyErrorMiddlewares = (app, config) => {
54
- if (config.servicePaused === true || config.servicePaused === 'true') {
55
- app.use(hofMiddleware.servicePaused({
54
+ if (config.serviceUnavailable === true) {
55
+ app.use(hofMiddleware.serviceUnavailable({
56
56
  logger: config.logger
57
57
  }));
58
58
  }
@@ -161,7 +161,7 @@ function bootstrap(options) {
161
161
  res.locals.sessionTimeoutWarningContent = config.sessionTimeoutWarningContent;
162
162
  res.locals.exitFormContent = config.exitFormContent;
163
163
  res.locals.saveExitFormContent = config.saveExitFormContent;
164
- res.locals.servicePaused = config.servicePaused;
164
+ res.locals.serviceUnavailable = config.serviceUnavailable;
165
165
  next();
166
166
  });
167
167
 
@@ -238,10 +238,12 @@ function bootstrap(options) {
238
238
  // Set up routing so <YOUR-SITE-URL>/assets are served from /node_modules/govuk-frontend/govuk/assets
239
239
  app.use('/assets', express.static(path.join(__dirname, '/node_modules/govuk-frontend/govuk/assets')));
240
240
  // Check if service has been paused and redirect accordingly
241
- if (config.servicePaused === true || config.servicePaused === 'true') {
241
+ const bypassPaths = ['/assets', '/healthcheck', '/service-unavailable'];
242
+
243
+ if (config.serviceUnavailable === true) {
242
244
  app.use((req, res, next) => {
243
- if (req.path !== '/service-paused') {
244
- return res.redirect('/service-paused');
245
+ if (!bypassPaths.some(bypassPath => req.path.startsWith(bypassPath))) {
246
+ return res.redirect('/service-unavailable');
245
247
  }
246
248
  return next();
247
249
  });
@@ -5,71 +5,75 @@ const rateLimitsConfig = require('../config/rate-limits');
5
5
 
6
6
  const errorTitle = code => `${code}_ERROR`;
7
7
  const errorMsg = code => `There is a ${code}_ERROR`;
8
+
8
9
  // eslint-disable-next-line complexity
9
10
  const getContent = (err, translate) => {
10
11
  const content = {};
11
12
 
13
+ // Helper to safely call translate if it's a function
14
+ const t = key => (typeof translate === 'function' ? translate(key) : undefined);
15
+
12
16
  if (err.code === 'SESSION_TIMEOUT') {
13
17
  err.status = 401;
14
18
  err.template = 'session-timeout';
15
- err.serviceName = (translate && translate('journey.serviceName') || translate('journey.header'));
16
- err.title = (translate && translate('errors.session.title'));
17
- err.message = (translate && translate('errors.session.message'));
18
- content.serviceName = (translate && translate('journey.serviceName') || translate('journey.header'));
19
- content.title = (translate && translate('errors.session.title'));
20
- content.message = (translate && translate('errors.session.message'));
19
+ err.serviceName = t('journey.serviceName') || t('journey.header');
20
+ err.title = t('errors.session.title');
21
+ err.message = t('errors.session.message');
22
+ content.serviceName = t('journey.serviceName') || t('journey.header');
23
+ content.title = t('errors.session.title');
24
+ content.message = t('errors.session.message');
21
25
  }
22
26
 
23
27
  if (err.code === 'NO_COOKIES') {
24
28
  err.status = 403;
25
29
  err.template = 'cookie-error';
26
- content.serviceName = (translate && translate('journey.serviceName') || translate('journey.header'));
27
- content.title = (translate && translate('errors.cookies-required.title'));
28
- content.message = (translate && translate('errors.cookies-required.message'));
30
+ content.serviceName = t('journey.serviceName') || t('journey.header');
31
+ content.title = t('errors.cookies-required.title');
32
+ content.message = t('errors.cookies-required.message');
29
33
  }
30
34
 
31
35
  if (err.code === 'DDOS_RATE_LIMIT') {
32
36
  err.status = 429;
33
37
  err.template = 'rate-limit-error';
34
- err.serviceName = (translate && translate('journey.serviceName') || translate('journey.header'));
35
- err.title = (translate && translate('errors.ddos-rate-limit.title'));
36
- err.message = (translate && translate('errors.ddos-rate-limit.message'));
37
- err.preTimeToWait = (translate && translate('errors.ddos-rate-limit.pre-time-to-wait'));
38
+ err.serviceName = t('journey.serviceName') || t('journey.header');
39
+ err.title = t('errors.ddos-rate-limit.title');
40
+ err.message = t('errors.ddos-rate-limit.message');
41
+ err.preTimeToWait = t('errors.ddos-rate-limit.pre-time-to-wait');
38
42
  err.timeToWait = rateLimitsConfig.rateLimits.requests.windowSizeInMinutes;
39
- err.postTimeToWait = (translate && translate('errors.ddos-rate-limit.post-time-to-wait'));
40
- content.title = (translate && translate('errors.ddos-rate-limit.title'));
41
- content.serviceName = (translate && translate('journey.serviceName') || translate('journey.header'));
42
- content.message = (translate && translate('errors.ddos-rate-limit.message'));
43
- content.preTimeToWait = (translate && translate('errors.ddos-rate-limit.pre-time-to-wait'));
43
+ err.postTimeToWait = t('errors.ddos-rate-limit.post-time-to-wait');
44
+ content.title = t('errors.ddos-rate-limit.title');
45
+ content.serviceName = t('journey.serviceName') || t('journey.header');
46
+ content.message = t('errors.ddos-rate-limit.message');
47
+ content.preTimeToWait = t('errors.ddos-rate-limit.pre-time-to-wait');
44
48
  content.timeToWait = rateLimitsConfig.rateLimits.requests.windowSizeInMinutes;
45
- content.postTimeToWait = (translate && translate('errors.ddos-rate-limit.post-time-to-wait'));
49
+ content.postTimeToWait = t('errors.ddos-rate-limit.post-time-to-wait');
46
50
  }
47
51
 
48
52
  if (err.code === 'SUBMISSION_RATE_LIMIT') {
49
53
  err.status = 429;
50
54
  err.template = 'rate-limit-error';
51
- err.serviceName = (translate && translate('journey.serviceName') || translate('journey.header'));
52
- err.title = (translate && translate('errors.submission-rate-limit.title'));
53
- err.message = (translate && translate('errors.submission-rate-limit.message'));
54
- err.preTimeToWait = (translate && translate('errors.submission-rate-limit.pre-time-to-wait'));
55
+ err.serviceName = t('journey.serviceName') || t('journey.header');
56
+ err.title = t('errors.submission-rate-limit.title');
57
+ err.message = t('errors.submission-rate-limit.message');
58
+ err.preTimeToWait = t('errors.submission-rate-limit.pre-time-to-wait');
55
59
  err.timeToWait = rateLimitsConfig.rateLimits.submissions.windowSizeInMinutes;
56
- err.postTimeToWait = (translate && translate('errors.submission-rate-limit.post-time-to-wait'));
57
- content.serviceName = (translate && translate('journey.serviceName') || translate('journey.header'));
58
- content.title = (translate && translate('errors.submission-rate-limit.title'));
59
- content.message = (translate && translate('errors.submission-rate-limit.message'));
60
- content.preTimeToWait = (translate && translate('errors.submission-rate-limit.pre-time-to-wait'));
60
+ err.postTimeToWait = t('errors.submission-rate-limit.post-time-to-wait');
61
+ content.serviceName = t('journey.serviceName') || t('journey.header');
62
+ content.title = t('errors.submission-rate-limit.title');
63
+ content.message = t('errors.submission-rate-limit.message');
64
+ content.preTimeToWait = t('errors.submission-rate-limit.pre-time-to-wait');
61
65
  content.timeToWait = rateLimitsConfig.rateLimits.submissions.windowSizeInMinutes;
62
- content.postTimeToWait = (translate && translate('errors.submission-rate-limit.post-time-to-wait'));
66
+ content.postTimeToWait = t('errors.submission-rate-limit.post-time-to-wait');
63
67
  }
64
68
 
65
69
  err.code = err.code || 'UNKNOWN';
66
70
  err.status = err.status || 500;
67
71
 
68
72
  if (!content.title) {
69
- content.title = (translate && translate('errors.default.title')) || errorTitle(err.code);
73
+ content.title = t('errors.default.title') || errorTitle(err.code);
70
74
  }
71
75
  if (!content.message) {
72
- content.message = (translate && translate('errors.default.message')) || errorMsg(err.code);
76
+ content.message = t('errors.default.message') || errorMsg(err.code);
73
77
  }
74
78
  return content;
75
79
  };
@@ -6,5 +6,5 @@ module.exports = {
6
6
  notFound: require('./not-found'),
7
7
  deepTranslate: require('./deep-translate'),
8
8
  rateLimiter: require('./rate-limiter'),
9
- servicePaused: require('./service-paused')
9
+ serviceUnavailable: require('./service-unavailable')
10
10
  };
@@ -0,0 +1,64 @@
1
+ /* eslint-disable consistent-return */
2
+ 'use strict';
3
+
4
+ const getTranslations = translate => {
5
+ const translations = {
6
+ title: 'Sorry, this service is unavailable',
7
+ message: 'This service is temporarily unavailable',
8
+ 'answers-saved': 'Your answers have not been saved'
9
+ };
10
+
11
+ if (translate) {
12
+ const contact = translate('errors.service-unavailable.contact');
13
+ const alternative = translate('errors.service-unavailable.alternative');
14
+ translations.serviceName = translate('journey.serviceName') || translate('journey.header');
15
+ translations.title = translate('errors.service-unavailable.title');
16
+ translations.message = translate('errors.service-unavailable.message');
17
+ translations['answers-saved'] = translate('errors.service-unavailable.answers-saved');
18
+
19
+ // Only render contact and alternative information if the key has a value set
20
+ if (contact === 'errors.service-unavailable.contact') {
21
+ translations.contact = '';
22
+ } else {
23
+ translations.contact = translate('errors.service-unavailable.contact');
24
+ }
25
+ if (alternative === 'errors.service-unavailable.alternative') {
26
+ translations.alternative = '';
27
+ } else {
28
+ translations.alternative = translate('errors.service-unavailable.alternative');
29
+ }
30
+ }
31
+ return translations;
32
+ };
33
+
34
+ module.exports = options => {
35
+ const opts = options || {};
36
+ const logger = opts.logger;
37
+ // These are paths that are allowed to bypass the "service unavailable" middleware.
38
+ // When the service is unavailable (for example, for maintenance), all routes except those listed here
39
+ // will return a paused response, typically a maintenance page.
40
+ //
41
+ // - '/assets': Static assets (CSS, JS, images) must still be served so the paused page displays correctly.
42
+ // - '/readyz' and '/livez': Health check endpoints must remain available for Kubernetes or other orchestration
43
+ // systems to determine if the container is healthy, even during maintenance.
44
+ const bypassPaths = opts.bypassPaths || ['/readyz', '/health', '/assets'];
45
+
46
+ return (req, res, next) => {
47
+ if (bypassPaths.some(path => req.path.startsWith(path))) {
48
+ return next();
49
+ }
50
+ const translate = opts.translate || req.translate;
51
+ const translations = getTranslations(translate);
52
+ if (logger && logger.warn) {
53
+ logger.warn('Service temporarily unavailable - service paused.');
54
+ }
55
+ res.status(503).render('service-unavailable', {
56
+ serviceName: translations.serviceName,
57
+ title: translations.title,
58
+ message: translations.message,
59
+ 'answers-saved': translations['answers-saved'],
60
+ contact: translations.contact,
61
+ alternative: translations.alternative
62
+ });
63
+ };
64
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hof",
3
3
  "description": "A bootstrap for HOF projects",
4
- "version": "22.7.0-service-paused-beta.13",
4
+ "version": "22.7.0-service-unavailable-beta.2",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
7
7
  "author": "HomeOffice",
@@ -27,7 +27,7 @@
27
27
  "test:lint": "eslint . --config ./node_modules/eslint-config-hof/default.js",
28
28
  "test:functional": "funkie mocha ./test/functional-tests --timeout 20000 --exit",
29
29
  "test:client": "karma start test/frontend/toolkit/karma.conf.js",
30
- "test:jest": "jest test/frontend/jest",
30
+ "test:jest": "jest test/frontend/jest test/utilities/helpers/jest",
31
31
  "test:acceptance": "TAGS=\"${TAGS:=@feature}\" yarn run test:cucumber",
32
32
  "test:acceptance_browser": "ACCEPTANCE_WITH_BROWSER=true TAGS=\"${TAGS:=@feature}\" yarn run test:cucumber",
33
33
  "test:cucumber": "cucumber-js -f @cucumber/pretty-formatter \"sandbox/test/_features/**/*.feature\" --require sandbox/test/_features/test.setup.js --require \"sandbox/test/_features/step_definitions/**/*.js\" --tags $TAGS",
package/sandbox/.env CHANGED
@@ -1 +1,2 @@
1
1
  SERVICE_PAUSED=false
2
+ # REDIRECT_TO_PAGE=true
@@ -1,9 +1,4 @@
1
1
  {
2
- "errors": {
3
- "service-paused": {
4
- "contact": "You can email for more information"
5
- }
6
- },
7
2
  "exit": {
8
3
  "header": "You have left this form",
9
4
  "title": "You have left this form"
@@ -111,7 +106,6 @@
111
106
  },
112
107
  "journey": {
113
108
  "header": "HOF Bootstrap Sandbox Form",
114
- "serviceName": "HOF Bootstrap Sandbox Form",
115
109
  "confirmation": {
116
110
  "details": "Your reference number <br><strong>HDJ2123F</strong>"
117
111
  }
@@ -186,6 +180,10 @@
186
180
  "paragraph-1": "Your form doesn't appear to have been worked on for 30 minutes so we closed it for security.",
187
181
  "paragraph-2": "Any answers you saved have not been affected.",
188
182
  "paragraph-3": "You can sign back in to your application at any time by returning to the <a href='/' class='govuk-link'>start page</a>."
183
+ },
184
+ "service-down": {
185
+ "message": "hello",
186
+ "descriptions": "hello"
189
187
  }
190
188
  },
191
189
  "validation": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "service-paused": {
2
+ "service-unavailable": {
3
3
  "contact": "You can email for more information"
4
4
  }
5
5
  }
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node server.js",
11
- "start:dev": "HOF_SANDBOX=true ../bin/hof-build watch --env",
11
+ "start:dev": "HOF_SANDBOX=true ../bin/hof-build watch",
12
12
  "dev": "yarn && GA_TAG=test nodemon server",
13
13
  "build": "HOF_SANDBOX=true ../bin/hof-build",
14
14
  "postinstall": "yarn run build"
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const _ = require('lodash');
5
+ const logger = require('../../lib/logger');
5
6
 
6
7
  module.exports = class Helpers {
7
8
  /**
@@ -146,4 +147,43 @@ module.exports = class Helpers {
146
147
  (!Object.keys(req.form.values).includes(condition.field) &&
147
148
  _.get(req, `form.historicalValues[${condition.field}]`)));
148
149
  }
150
+
151
+ /**
152
+ * Read an environment variable and coerce it to boolean.
153
+ *
154
+ * @param {*} rawValue The raw value (e.g. process.env.FEATURE_FLAG)
155
+ * @param {boolean} [fallback=false] Value to use if rawValue is null/undefined/invalid
156
+ * @param {string} [envVarName='ENV_VAR'] Name for log context
157
+ * @returns {boolean}
158
+ */
159
+ static getEnvBoolean(rawValue, fallback = false, envVarName = 'ENV_VAR') {
160
+ if (typeof rawValue === 'boolean') {
161
+ return rawValue;
162
+ }
163
+
164
+ if (rawValue === null || rawValue === undefined) {
165
+ return fallback;
166
+ }
167
+
168
+ if (typeof rawValue === 'string') {
169
+ const normalized = rawValue.trim().toLowerCase();
170
+ if (normalized === 'true') {
171
+ return true;
172
+ }
173
+ if (normalized === 'false') {
174
+ return false;
175
+ }
176
+ logger.warn(
177
+ `Invalid environment variable ${envVarName} value "${rawValue}" ` +
178
+ '– must be "true" or "false"; defaulting to ' +
179
+ `${fallback}`
180
+ );
181
+ return fallback;
182
+ }
183
+
184
+ logger.warn(
185
+ `Invalid environment variable ${envVarName} type (${typeof rawValue}); defaulting to ${fallback}`
186
+ );
187
+ return fallback;
188
+ }
149
189
  };
@@ -1,8 +0,0 @@
1
- {
2
- "service-paused": {
3
- "title": "Sorry, the service is unavailable",
4
- "header": "Sorry, the service is unavailable",
5
- "message": "This service is temporarily unavailable",
6
- "answers-saved": "Your answers have not been saved"
7
- }
8
- }
@@ -1,52 +0,0 @@
1
- 'use strict';
2
-
3
- const getTranslations = translate => {
4
- const translations = {
5
- title: 'Sorry, this service is unavailable',
6
- message: 'This service is temporarily unavailable',
7
- 'answers-saved': 'Your answers have not been saved'
8
- };
9
-
10
- if (translate) {
11
- const contact = translate('errors.service-paused.contact');
12
- const alternative = translate('errors.service-paused.alternative');
13
- translations.serviceName = translate('journey.serviceName') || translate('journey.header');
14
- translations.title = translate('errors.service-paused.title');
15
- translations.message = translate('errors.service-paused.message');
16
- translations['answers-saved'] = translate('errors.service-paused.answers-saved');
17
-
18
- // Only render contact and alternative information if the key has a value set
19
- if (contact === 'errors.service-paused.contact') {
20
- translations.contact = '';
21
- } else {
22
- translations.contact = translate('errors.service-paused.contact');
23
- }
24
- if (alternative === 'errors.service-paused.alternative') {
25
- translations.alternative = '';
26
- } else {
27
- translations.alternative = translate('errors.service-paused.alternative');
28
- }
29
- }
30
- return translations;
31
- };
32
-
33
- module.exports = options => {
34
- const opts = options || {};
35
- const logger = opts.logger;
36
-
37
- return (req, res) => {
38
- const translate = opts.translate || req.translate;
39
- const translations = getTranslations(translate);
40
- if (logger && logger.warn) {
41
- logger.warn('Service temporarily unavailable - service paused.');
42
- }
43
- res.status(503).render('service-paused', {
44
- serviceName: translations.serviceName,
45
- title: translations.title,
46
- message: translations.message,
47
- 'answers-saved': translations['answers-saved'],
48
- contact: translations.contact,
49
- alternative: translations.alternative
50
- });
51
- };
52
- };