@dwp/govuk-casa 8.11.1 → 8.13.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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/dist/casa.d.ts +29 -1
  3. package/dist/casa.js +24 -1
  4. package/dist/casa.js.map +1 -1
  5. package/dist/lib/JourneyContext.d.ts +32 -6
  6. package/dist/lib/JourneyContext.js +70 -11
  7. package/dist/lib/JourneyContext.js.map +1 -1
  8. package/dist/lib/configuration-ingestor.d.ts +11 -1
  9. package/dist/lib/configuration-ingestor.js +61 -1
  10. package/dist/lib/configuration-ingestor.js.map +1 -1
  11. package/dist/lib/configure.js +6 -2
  12. package/dist/lib/configure.js.map +1 -1
  13. package/dist/lib/constants.d.ts +2 -0
  14. package/dist/lib/constants.js +3 -1
  15. package/dist/lib/constants.js.map +1 -1
  16. package/dist/lib/context-id-generators.d.ts +27 -0
  17. package/dist/lib/context-id-generators.js +54 -0
  18. package/dist/lib/context-id-generators.js.map +1 -0
  19. package/dist/middleware/data.d.ts +2 -1
  20. package/dist/middleware/data.js +7 -1
  21. package/dist/middleware/data.js.map +1 -1
  22. package/dist/middleware/gather-fields.js +1 -1
  23. package/dist/middleware/gather-fields.js.map +1 -1
  24. package/dist/middleware/sanitise-fields.js +1 -1
  25. package/dist/middleware/sanitise-fields.js.map +1 -1
  26. package/dist/mjs/esm-wrapper.js +1 -0
  27. package/dist/routes/journey.d.ts +1 -1
  28. package/dist/routes/journey.js +35 -10
  29. package/dist/routes/journey.js.map +1 -1
  30. package/package.json +1 -2
  31. package/src/casa.js +27 -0
  32. package/src/lib/JourneyContext.js +79 -11
  33. package/src/lib/configuration-ingestor.js +42 -0
  34. package/src/lib/configure.js +7 -0
  35. package/src/lib/constants.js +2 -0
  36. package/src/lib/context-id-generators.js +71 -0
  37. package/src/middleware/data.js +8 -0
  38. package/src/middleware/gather-fields.js +4 -1
  39. package/src/middleware/sanitise-fields.js +1 -1
  40. package/src/routes/journey.js +36 -9
@@ -1,3 +1,4 @@
1
+ /* eslint-disable import/no-cycle */
1
2
  /**
2
3
  * Represents the state of a user's journey through the Plan. It contains
3
4
  * information about:
@@ -6,11 +7,11 @@
6
7
  * - Validation errors on that data
7
8
  * - Navigation information about how the user got where they are.
8
9
  */
9
- import { v4 as uuidv4, validate as uuidValidate } from 'uuid';
10
10
  import lodash from 'lodash';
11
11
  import ValidationError from './ValidationError.js';
12
12
  import logger from './logger.js';
13
13
  import { notProto } from './utils.js';
14
+ import { uuid as uuidGenerator } from './context-id-generators.js';
14
15
 
15
16
  const {
16
17
  cloneDeep, isPlainObject, isObject, has, isEqual,
@@ -18,6 +19,8 @@ const {
18
19
 
19
20
  const log = logger('lib:journey-context');
20
21
 
22
+ const uuid = uuidGenerator();
23
+
21
24
  /**
22
25
  * @access private
23
26
  * @typedef {import('../casa').ContextEventUserInfo} ContextEventUserInfo
@@ -75,6 +78,16 @@ export default class JourneyContext {
75
78
 
76
79
  static DEFAULT_CONTEXT_ID = 'default';
77
80
 
81
+ /**
82
+ * @type {symbol}
83
+ */
84
+ static ID_GENERATOR_REQ_LOG = Symbol('generatedContextIds');
85
+
86
+ /**
87
+ * @type {symbol}
88
+ */
89
+ static ID_GENERATOR_REQ_KEY = Symbol('generateContextId');
90
+
78
91
  /**
79
92
  * Constructor.
80
93
  *
@@ -476,12 +489,15 @@ export default class JourneyContext {
476
489
  /**
477
490
  * Construct a new ephemeral JourneyContext instance with a unique ID.
478
491
  *
492
+ * Note: In later versions of CASA, the `req` property will be mandatory.
493
+ *
494
+ * @param {ExpressRequest} [req] Request session
479
495
  * @returns {JourneyContext} Constructed JourneyContext instance
480
496
  */
481
- static createEphemeralContext() {
497
+ static createEphemeralContext(req) {
482
498
  return JourneyContext.fromObject({
483
499
  identity: {
484
- id: uuidv4(),
500
+ id: JourneyContext.generateContextId(req),
485
501
  },
486
502
  });
487
503
  }
@@ -489,17 +505,20 @@ export default class JourneyContext {
489
505
  /**
490
506
  * Construct a new JourneyContext instance from another instance.
491
507
  *
508
+ * Note: In later versions of CASA, the `req` property will be mandatory.
509
+ *
492
510
  * @param {JourneyContext} context Context to copy from
511
+ * @param {ExpressRequest} [req] Request
493
512
  * @returns {JourneyContext} Constructed JourneyContext instance
494
513
  * @throws {TypeError} When context is not a valid type
495
514
  */
496
- static fromContext(context) {
515
+ static fromContext(context, req) {
497
516
  if (!(context instanceof JourneyContext)) {
498
517
  throw new TypeError('Source context must be a JourneyContext');
499
518
  }
500
519
 
501
520
  const newContextObj = context.toObject();
502
- newContextObj.identity.id = uuidv4();
521
+ newContextObj.identity.id = JourneyContext.generateContextId(req);
503
522
 
504
523
  return JourneyContext.fromObject(newContextObj);
505
524
  }
@@ -542,14 +561,14 @@ export default class JourneyContext {
542
561
  }
543
562
 
544
563
  /**
545
- * Validate the format of a context ID, i.e. "default" or a uuid
546
- * eg 00000000-0000-0000-0000-000000000000
547
- * eg 123e4567-e89b-12d3-a456-426614174000
564
+ * Validate the format of a context ID:
565
+ * - Between 1 and 64 characters
566
+ * - Contain only the characters a-z, 0-9, -
548
567
  *
549
568
  * @param {string} id Context ID
550
569
  * @returns {string} Original ID if it's valid
551
570
  * @throws {TypeError} When id is not a valid type
552
- * @throws {SyntaxError} When id is not a valid uuid format
571
+ * @throws {SyntaxError} When id is not a valid format
553
572
  */
554
573
  static validateContextId(id) {
555
574
  if (id === JourneyContext.DEFAULT_CONTEXT_ID) {
@@ -558,9 +577,58 @@ export default class JourneyContext {
558
577
 
559
578
  if (typeof id !== 'string') {
560
579
  throw new TypeError('Context ID must be a string');
561
- } else if (!uuidValidate(id)) {
562
- throw new SyntaxError('Context ID is not in the correct uuid format');
580
+ } else if (!id.match(/^[a-z0-9-]{1,64}$/)) {
581
+ throw new SyntaxError('Context ID is not in the correct format');
582
+ }
583
+
584
+ return id;
585
+ }
586
+
587
+ /**
588
+ * Generate a new context ID, validate it, and throw if the ID has already been
589
+ * generated during this request lifecycle. This may happen if an ID was
590
+ * generated, but never used to store a new context in the session. Therefore
591
+ * it is important for user code to always call `putContext()` before
592
+ * generating another ID.
593
+ *
594
+ * @param {ExpressRequest} [req] Request
595
+ * @returns {string} New ID
596
+ * @throws {Error} When generated ID has already been used
597
+ */
598
+ static generateContextId(req) {
599
+ // Can't generate custom ID when no request object is provided, because the
600
+ // custom generator function itself exists on that object.
601
+ if (!req) {
602
+ log.warn('Generating a context ID without a given request object. Reverting to uuid().');
603
+ return uuid();
604
+ }
605
+
606
+ // Collate a list of context IDs already in use, either from existing
607
+ // contexts in the session, or generated during this request lifecycle.
608
+ // We don't identify the source of each ID because the generator must not
609
+ // differentiate its behaviour on whether the ID exists in session or not.
610
+ const inSessionIds = JourneyContext.getContexts(req.session)
611
+ .map((c) => c.identity.id)
612
+ .filter((id) => id !== JourneyContext.DEFAULT_CONTEXT_ID);
613
+ const inRequestIds = req[JourneyContext.ID_GENERATOR_REQ_LOG] ?? [];
614
+ const reservedIds = Array.from(new Set([...inSessionIds, ...inRequestIds]).values());
615
+
616
+ // Generate and log the ID
617
+ const id = JourneyContext.validateContextId(
618
+ req[JourneyContext.ID_GENERATOR_REQ_KEY].call(null, { req, reservedIds }),
619
+ );
620
+ if (reservedIds.includes(id)) {
621
+ throw new Error(`Regenerated a context ID, ${String(id)}. It has likely not yet been used to store a new context in session using JourneyContext.putContext().`);
622
+ }
623
+
624
+ if (!req[JourneyContext.ID_GENERATOR_REQ_LOG]) {
625
+ Object.defineProperty(req, JourneyContext.ID_GENERATOR_REQ_LOG, {
626
+ value: [],
627
+ enumerable: false,
628
+ writable: false,
629
+ });
563
630
  }
631
+ req[JourneyContext.ID_GENERATOR_REQ_LOG].push(id);
564
632
 
565
633
  return id;
566
634
  }
@@ -9,6 +9,8 @@ import {
9
9
  validateHookPath,
10
10
  validateView,
11
11
  } from './utils.js';
12
+ import * as contextIdGenerators from './context-id-generators.js';
13
+ import { CONFIG_ERROR_VISIBILITY_ALWAYS, CONFIG_ERROR_VISIBILITY_ONSUBMIT } from './constants.js';
12
14
 
13
15
  /**
14
16
  * @access private
@@ -255,6 +257,25 @@ export function validateSessionCookiePath(cookiePath, defaultPath = '/') {
255
257
  * @returns {boolean} cookie path
256
258
  * @throws {TypeError} When invalid arguments are provided
257
259
  */
260
+
261
+ /**
262
+ * Validates errorVisibility.
263
+ *
264
+ * @access private
265
+ * @param {string} errorVisibility sets visibility flag for page validation error
266
+ * @throws {SyntaxError} For invalid errorVisibility flag.
267
+ * @returns {symbol | Function} flag for error visibility.
268
+ */
269
+ export function validateErrorVisibility(errorVisibility = CONFIG_ERROR_VISIBILITY_ONSUBMIT) {
270
+ if (errorVisibility === undefined) {
271
+ return undefined;
272
+ }
273
+ if (errorVisibility === CONFIG_ERROR_VISIBILITY_ALWAYS || errorVisibility === CONFIG_ERROR_VISIBILITY_ONSUBMIT || typeof errorVisibility === 'function') {
274
+ return errorVisibility;
275
+ }
276
+ throw new TypeError('errorVisibility must be casa constant CONFIG_ERROR_VISIBILITY_ALWAYS | CONFIG_ERROR_VISIBILITY_ONSUBMIT or function');
277
+ }
278
+
258
279
  export function validateSessionCookieSameSite(cookieSameSite, defaultFlag) {
259
280
  const validValues = [true, false, 'Strict', 'Lax', 'None'];
260
281
 
@@ -321,6 +342,9 @@ const validatePage = (page, index) => {
321
342
  if (page.hooks !== undefined) {
322
343
  validatePageHooks(page.hooks);
323
344
  }
345
+ if (page.errorVisibility !== undefined) {
346
+ validateErrorVisibility(page.errorVisibility)
347
+ }
324
348
  } catch (err) {
325
349
  err.message = `Page at index ${index} is invalid: ${err.message}`;
326
350
  throw err;
@@ -435,6 +459,18 @@ export function validateFormMaxBytes(value, defaultValue = 1024 * 50) {
435
459
  return parsedValue;
436
460
  }
437
461
 
462
+ export function validateContextIdGenerator(generator) {
463
+ if (generator === undefined) {
464
+ return contextIdGenerators.uuid();
465
+ }
466
+
467
+ if (!(generator instanceof Function)) {
468
+ throw new TypeError('contextIdGenerator must be a function');
469
+ }
470
+
471
+ return generator;
472
+ }
473
+
438
474
  /**
439
475
  * Ingest, validate, sanitise and manipulate configuration parameters.
440
476
  *
@@ -454,6 +490,9 @@ export default function ingest(config = {}) {
454
490
  // URL that will prefix all URLs in the browser address bar
455
491
  mountUrl: validateMountUrl(config.mountUrl),
456
492
 
493
+ // flag to make validation error visible on get requests
494
+ errorVisibility: validateErrorVisibility(config.errorVisibility),
495
+
457
496
  // Session
458
497
  session: validateSessionObject(config.session, (session) => ({
459
498
  name: validateSessionName(session.name),
@@ -489,6 +528,9 @@ export default function ingest(config = {}) {
489
528
  // Form parsing
490
529
  formMaxParams: validateFormMaxParams(config.formMaxParams, 25),
491
530
  formMaxBytes: validateFormMaxBytes(config.formMaxBytes, 1024 * 50),
531
+
532
+ // Context ID generator
533
+ contextIdGenerator: validateContextIdGenerator(config.contextIdGenerator),
492
534
  };
493
535
 
494
536
  // Freeze to modifications
@@ -22,6 +22,8 @@ import dataMiddlewareFactory from '../middleware/data.js';
22
22
  import bodyParserMiddlewareFactory from '../middleware/body-parser.js';
23
23
  import csrfMiddlewareFactory from '../middleware/csrf.js';
24
24
 
25
+ import { CONFIG_ERROR_VISIBILITY_ONSUBMIT } from './constants.js';
26
+
25
27
  /**
26
28
  * @access private
27
29
  * @typedef {import('../casa').ConfigurationOptions} ConfigurationOptions
@@ -55,6 +57,7 @@ export default function configure(config = {}) {
55
57
  const ingestedConfig = configurationIngestor(config);
56
58
  const {
57
59
  mountUrl,
60
+ errorVisibility = CONFIG_ERROR_VISIBILITY_ONSUBMIT,
58
61
  views = [],
59
62
  session = {
60
63
  secret: 'secret',
@@ -77,6 +80,7 @@ export default function configure(config = {}) {
77
80
  helmetConfigurator = undefined,
78
81
  formMaxParams,
79
82
  formMaxBytes,
83
+ contextIdGenerator,
80
84
  } = ingestedConfig;
81
85
 
82
86
  // Prepare all page hooks so they are prefixed with the `journey.` scope.
@@ -124,6 +128,7 @@ export default function configure(config = {}) {
124
128
  const dataMiddleware = dataMiddlewareFactory({
125
129
  plan,
126
130
  events,
131
+ contextIdGenerator,
127
132
  });
128
133
 
129
134
  // Prepare form middleware and its constituent parts
@@ -143,11 +148,13 @@ export default function configure(config = {}) {
143
148
  });
144
149
 
145
150
  // Setup waypoint router, which includes routes for every defined waypoint
151
+ const globalErrorVisibility = errorVisibility
146
152
  const journeyRouter = journeyRoutes({
147
153
  globalHooks: hooks,
148
154
  pages,
149
155
  plan,
150
156
  csrfMiddleware,
157
+ globalErrorVisibility,
151
158
  });
152
159
 
153
160
  // Create the mounting function
@@ -7,3 +7,5 @@ export const REQUEST_PHASE_GATHER = Symbol('gather');
7
7
  export const REQUEST_PHASE_VALIDATE = Symbol('validate');
8
8
  export const REQUEST_PHASE_REDIRECT = Symbol('redirect');
9
9
  export const REQUEST_PHASE_RENDER = Symbol('render');
10
+ export const CONFIG_ERROR_VISIBILITY_ONSUBMIT = Symbol('onsubmit');
11
+ export const CONFIG_ERROR_VISIBILITY_ALWAYS = Symbol('always');
@@ -0,0 +1,71 @@
1
+ /* eslint-disable import/no-cycle */
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ /**
5
+ * @typedef {import('../casa.js').ContextIdGenerator} ContextIdGenerator
6
+ */
7
+
8
+ /**
9
+ * Creates an instance of a UUID generator.
10
+ *
11
+ * @returns {ContextIdGenerator} Generator function
12
+ */
13
+ const uuid = () => () => randomUUID();
14
+
15
+ /**
16
+ * Returns a generator that returns the next incremental integer in a sequence.
17
+ *
18
+ * This generator does not take into account the removal of any contexts from
19
+ * session that were previously assigned a sequential ID. This means that IDs
20
+ * will be re-used when they are freed up.
21
+ *
22
+ * @returns {ContextIdGenerator} Generator function
23
+ */
24
+ const sequentialInteger = () => ({ reservedIds }) => {
25
+ const contextIds = Array.from(reservedIds).sort();
26
+
27
+ if (!contextIds.length) {
28
+ return '1';
29
+ }
30
+
31
+ // Find the first numeric ID that we can increment
32
+ let lastInSequence;
33
+ do {
34
+ lastInSequence = Number.parseInt(contextIds.pop(), 10);
35
+ } while (contextIds.length && Number.isNaN(lastInSequence));
36
+
37
+ return String(!Number.isNaN(lastInSequence) ? lastInSequence + 1 : 1);
38
+ };
39
+
40
+ const shortGuid = ({
41
+ length = 5,
42
+ prefix = '',
43
+ pool = 'abcdefhkmnprtwxy346789',
44
+ } = {}) => ({ reservedIds }) => {
45
+ // Ambiguous characters excluded
46
+ const poolSize = pool.length;
47
+
48
+ const maxAttempts = 10;
49
+ let attempts = maxAttempts;
50
+ let id;
51
+
52
+ do {
53
+ id = Array(length).fill(0).map(() => pool.charAt(Math.floor(Math.random() * poolSize))).join('');
54
+ attempts--;
55
+ } while (attempts > 0 && reservedIds.includes(id));
56
+
57
+ if (attempts === 0) {
58
+ throw new Error(`Failed to generate GUID after ${maxAttempts} iterations`);
59
+ }
60
+
61
+ return `${prefix}${id}`;
62
+ }
63
+
64
+ /**
65
+ * @namespace ContextIdGenerators
66
+ */
67
+ export {
68
+ uuid,
69
+ sequentialInteger,
70
+ shortGuid,
71
+ };
@@ -21,6 +21,7 @@ const editOrigin = (req) => {
21
21
  export default function dataMiddleware({
22
22
  plan,
23
23
  events,
24
+ contextIdGenerator,
24
25
  }) {
25
26
  return [
26
27
  (req, res, next) => {
@@ -45,6 +46,13 @@ export default function dataMiddleware({
45
46
  // Grab chosen language from session
46
47
  req.casa.journeyContext.nav.language = req.session.language;
47
48
 
49
+ // Context ID generator
50
+ Object.defineProperty(req, JourneyContext.ID_GENERATOR_REQ_KEY, {
51
+ value: contextIdGenerator,
52
+ enumerable: false,
53
+ writable: false,
54
+ });
55
+
48
56
  /* ------------------------------------------------- Template variables */
49
57
 
50
58
  // Capture mount URL that will be used in generating all browser URLs
@@ -29,7 +29,10 @@ export default ({
29
29
  (req, res, next) => {
30
30
  // Store a copy of the journey context before modifying it. This is useful
31
31
  // for any comparison work that may be done in subsequent middleware.
32
- req.casa.archivedJourneyContext = JourneyContext.fromContext(req.casa.journeyContext);
32
+ req.casa.archivedJourneyContext = JourneyContext.fromContext(
33
+ req.casa.journeyContext,
34
+ req,
35
+ );
33
36
 
34
37
  // Ignore data for any non-persistent fields
35
38
  // ESLint disabled as `fields`, `i` and `name` are dev-controlled
@@ -31,7 +31,7 @@ export default ({
31
31
  }
32
32
  /* eslint-enable security/detect-object-injection */
33
33
 
34
- const journeyContext = JourneyContext.fromContext(req.casa.journeyContext);
34
+ const journeyContext = JourneyContext.fromContext(req.casa.journeyContext, req);
35
35
  journeyContext.setDataForPage(waypoint, prunedBody);
36
36
 
37
37
  // Second, prune any fields that do not pass the validation conditional,
@@ -9,6 +9,7 @@ import progressJourneyMiddlewareFactory from '../middleware/progress-journey.js'
9
9
  import waypointUrl from '../lib/waypoint-url.js';
10
10
  import logger from '../lib/logger.js';
11
11
  import { resolveMiddlewareHooks } from '../lib/utils.js';
12
+ import { CONFIG_ERROR_VISIBILITY_ALWAYS } from '../lib/constants.js';
12
13
 
13
14
  const log = logger('routes:journey');
14
15
 
@@ -53,6 +54,26 @@ const renderMiddlewareFactory = (view, contextFactory) => [
53
54
  },
54
55
  ];
55
56
 
57
+ /**
58
+ * generate page validation error
59
+ *
60
+ * @param {object} errors object of page validation error
61
+ * @param {object} req casa request object
62
+ * @returns {object[]} array of error objects
63
+ */
64
+ const generateGovukErrors = (errors, req) => Object.values(errors || {}).map(([error]) => ({
65
+ text: req.t(error.summary, error.variables),
66
+ href: error.fieldHref,
67
+ }))
68
+ /**
69
+ * handle errorVisibility flag and function and return boolean
70
+ *
71
+ * @param {symbol | Function} errorVisibility errorVisibility config option
72
+ * @param {object} req casa request object
73
+ * @returns {boolean} true if errorVisibility is "always" or function condition true
74
+ */
75
+ const resolveErrorVisibility = (req, errorVisibility) => (typeof errorVisibility === 'function' ? errorVisibility({ req }) : errorVisibility === CONFIG_ERROR_VISIBILITY_ALWAYS)
76
+
56
77
  /**
57
78
  * Create an instance of the router for all waypoints visited during a Journey
58
79
  * through the Plan.
@@ -66,6 +87,7 @@ export default function journeyRouter({
66
87
  pages,
67
88
  plan,
68
89
  csrfMiddleware,
90
+ globalErrorVisibility,
69
91
  }) {
70
92
  // Router
71
93
  const router = new MutableRouter();
@@ -116,7 +138,7 @@ export default function journeyRouter({
116
138
  ];
117
139
 
118
140
  pages.forEach((page) => {
119
- const { waypoint, view, hooks: pageHooks = [], fields } = page;
141
+ const { waypoint, view, hooks: pageHooks = [], fields, errorVisibility } = page;
120
142
  const waypointPath = `/${waypoint}`;
121
143
 
122
144
  let commonWaypointMiddleware = [
@@ -145,10 +167,18 @@ export default function journeyRouter({
145
167
  ...resolveMiddlewareHooks('journey.poststeer', waypointPath, [...globalHooks, ...pageHooks]),
146
168
 
147
169
  ...resolveMiddlewareHooks('journey.prerender', waypointPath, [...globalHooks, ...pageHooks]),
148
- renderMiddlewareFactory(view, (req) => ({
149
- formUrl: waypointUrl({ mountUrl: `${req.baseUrl}/`, waypoint }),
150
- formData: req.casa.journeyContext.getDataForPage(waypoint),
151
- })),
170
+ renderMiddlewareFactory(view, (req) => {
171
+ const displayErrors = resolveErrorVisibility(req, globalErrorVisibility) || resolveErrorVisibility(req, errorVisibility);
172
+ const errors = displayErrors && (req.casa.journeyContext.getValidationErrorsForPageByField(waypoint) ?? Object.create(null));
173
+ const govukErrors = displayErrors && generateGovukErrors(errors, req);
174
+
175
+ return ({
176
+ formUrl: waypointUrl({ mountUrl: `${req.baseUrl}/`, waypoint }),
177
+ formData: req.casa.journeyContext.getDataForPage(waypoint),
178
+ formErrors: (Object.keys(errors).length && displayErrors) ? errors : null,
179
+ formErrorsGovukArray: (govukErrors.length && displayErrors) ? govukErrors : null,
180
+ })
181
+ }),
152
182
  );
153
183
 
154
184
  router.post(
@@ -193,10 +223,7 @@ export default function journeyRouter({
193
223
  // first one is shown.
194
224
  // Disabling security/detect-object-injection rule because both `errors`
195
225
  // and the `k` property are known entities
196
- const govukErrors = Object.keys(errors).map((k) => ({
197
- text: req.t(errors[k][0].summary, errors[k][0].variables), /* eslint-disable-line security/detect-object-injection */
198
- href: errors[k][0].fieldHref, /* eslint-disable-line security/detect-object-injection */
199
- }));
226
+ const govukErrors = generateGovukErrors(errors, req)
200
227
 
201
228
  return {
202
229
  formUrl: waypointUrl({ mountUrl: `${req.baseUrl}/`, waypoint }),