@defra/lis-infra-ui-services 0.44.0 → 0.45.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
@@ -100,15 +100,25 @@ It serves `/favicon.ico` and the files under each asset path. `/status-codes` ex
100
100
  `/config-formats` exports convict formats for settings the hubs and spokes share. Register each with `convict.addFormat()`; a bad value fails `config.validate()` with the setting named.
101
101
 
102
102
  - `positiveInteger` (`'positive-integer'`): a whole number of at least 1, for timeouts, TTLs and circuit breaker thresholds, where 0 is a misconfiguration rather than "off". Values from environment variables are coerced from strings.
103
+ - `duration({ max })` (`'duration'`, or `'duration-max-P7D'` when `max` is given): a length of time read as milliseconds. An environment variable may give an ISO 8601 duration of days, hours, minutes and seconds (`PT4H`, `P7D`, `P1DT12H`) or a whole number of milliseconds; both are coerced to milliseconds (the schema `default` must be a number). `max` is itself an ISO 8601 duration and rejects anything longer, for `SESSION_CACHE_TTL` where CDP requires sessions to expire within 7 days.
103
104
  - `secret` (`'secret'`): a signing or encryption secret of at least 32 characters with no default, for `HUB_JWT_SECRET` and `SESSION_COOKIE_PASSWORD`. The setting must also be marked `sensitive: true`, or convict appends the value to the validation error.
104
105
 
105
106
  ```js
106
107
  import convict from 'convict'
107
- import { positiveInteger, secret } from '@defra/lis-infra-ui-services/config-formats'
108
+ import { duration, positiveInteger, secret } from '@defra/lis-infra-ui-services/config-formats'
109
+ import { milliseconds } from '@defra/lis-infra-ui-services/duration'
108
110
 
111
+ const sessionTtl = duration({ max: 'P7D' })
109
112
  convict.addFormat(positiveInteger)
113
+ convict.addFormat(sessionTtl)
110
114
  convict.addFormat(secret)
111
115
 
116
+ sessionCacheTtl: {
117
+ format: sessionTtl.name,
118
+ default: milliseconds.fourHours,
119
+ env: 'SESSION_CACHE_TTL'
120
+ },
121
+
112
122
  timeout: {
113
123
  format: 'positive-integer',
114
124
  default: 5000,
@@ -138,7 +148,9 @@ await server.register(securityHeaders)
138
148
 
139
149
  `getCacheEngine` picks Redis or in-memory Catbox. `createSessionCachePluginForConfig` registers
140
150
  [yar](https://hapi.dev/module/yar/) from the `session` config (`cache.name`, `cache.ttl`,
141
- `cookie.password`, `cookie.ttl`). Session cookies are always `Secure`.
151
+ `cookie.password`, `cookie.ttl`). Session cookies are always `Secure`. Both engines cap the ttl of
152
+ every write at `MAX_CACHE_TTL` (7 days), so no cache policy can keep session data longer than
153
+ CDP allows.
142
154
 
143
155
  ```js
144
156
  import { getCacheEngine } from '@defra/lis-infra-ui-services/session-cache/cache-engine'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/lis-infra-ui-services",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "UI-Services shared definitions for the livestock taxonomy topology.",
5
5
  "main": "src/index.js",
6
6
  "repository": {
@@ -1,3 +1,5 @@
1
+ import { milliseconds } from './duration.js'
2
+
1
3
  /**
2
4
  * A convict format for settings that must be a whole number of at least 1,
3
5
  * such as timeouts and circuit breaker thresholds, where 0 is a
@@ -54,3 +56,88 @@ export const secret = {
54
56
  }
55
57
  }
56
58
  }
59
+
60
+ const ISO_DURATION_DATE = /^P(?:(\d+)D)?(?:T(.+))?$/
61
+ const ISO_DURATION_TIME = /^(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?$/
62
+ const MILLIS_IN_SECOND = 1000
63
+ const SECONDS_IN_MINUTE = 60
64
+ const MILLIS_IN_MINUTE = SECONDS_IN_MINUTE * MILLIS_IN_SECOND
65
+ const MILLIS_IN_HOUR = milliseconds.oneHour
66
+ const MILLIS_IN_DAY = milliseconds.oneDay
67
+
68
+ /**
69
+ * Parses an ISO 8601 duration made of days, hours, minutes and seconds
70
+ * (for example `P7D`, `PT4H`, `P1DT12H`) into milliseconds. Years, months
71
+ * and weeks are not supported because their length is not fixed.
72
+ *
73
+ * @param {string} value
74
+ * @returns {number | undefined} milliseconds, or undefined when it is not a supported duration
75
+ */
76
+ export function parseIsoDuration(value) {
77
+ const dateMatch = ISO_DURATION_DATE.exec(value.trim())
78
+ if (!dateMatch) {
79
+ return undefined
80
+ }
81
+ const [, days = 0, time] = dateMatch
82
+ const timeMatch = time === undefined ? [] : ISO_DURATION_TIME.exec(time)
83
+ if (!timeMatch || (days === 0 && !time) || timeMatch[0] === '') {
84
+ return undefined
85
+ }
86
+ const [, hours = 0, minutes = 0, seconds = 0] = timeMatch
87
+ return Math.round(
88
+ Number(days) * MILLIS_IN_DAY +
89
+ Number(hours) * MILLIS_IN_HOUR +
90
+ Number(minutes) * MILLIS_IN_MINUTE +
91
+ Number(seconds) * MILLIS_IN_SECOND
92
+ )
93
+ }
94
+
95
+ /**
96
+ * Builds a convict format for a duration setting that is read as
97
+ * milliseconds. An environment variable may give either an ISO 8601
98
+ * duration such as `PT4H` or `P7D`, or a whole number of milliseconds; both
99
+ * are coerced to milliseconds. Only values loaded from the environment are
100
+ * coerced, so the schema `default` must be a number of milliseconds. The
101
+ * value must be at least 1ms and, when `max` is given, no more than that
102
+ * duration. Register the result with
103
+ * `convict.addFormat(...)` and use its `name` as the setting's `format`.
104
+ *
105
+ * @param {{ max?: string }} [options] `max` is an ISO 8601 duration, e.g. `'P7D'`
106
+ * @returns {{ name: string, coerce: (value: unknown) => unknown, validate: (value: unknown) => void }}
107
+ */
108
+ export function duration({ max } = {}) {
109
+ const maxDuration = max?.trim()
110
+ const maxMilliseconds =
111
+ maxDuration === undefined ? undefined : parseIsoDuration(maxDuration)
112
+ if (maxDuration !== undefined && maxMilliseconds === undefined) {
113
+ throw new Error(`max must be an ISO 8601 duration, got ${max}`)
114
+ }
115
+ return {
116
+ name:
117
+ maxDuration === undefined ? 'duration' : `duration-max-${maxDuration}`,
118
+ coerce(value) {
119
+ if (typeof value !== 'string') {
120
+ return value
121
+ }
122
+ const trimmed = value.trim()
123
+ if (/^\d+$/.test(trimmed)) {
124
+ return Number(trimmed)
125
+ }
126
+ return parseIsoDuration(trimmed) ?? value
127
+ },
128
+ validate(value) {
129
+ if (
130
+ typeof value !== 'number' ||
131
+ !Number.isSafeInteger(value) ||
132
+ value < 1
133
+ ) {
134
+ throw new Error(
135
+ 'must be an ISO 8601 duration (e.g. PT4H) or a positive number of milliseconds'
136
+ )
137
+ }
138
+ if (maxMilliseconds !== undefined && value > maxMilliseconds) {
139
+ throw new Error(`must be no more than ${maxDuration}`)
140
+ }
141
+ }
142
+ }
143
+ }
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, test } from 'vitest'
2
2
  import convict from 'convict'
3
3
 
4
- import { positiveInteger, secret } from './config-formats.js'
4
+ import {
5
+ duration,
6
+ parseIsoDuration,
7
+ positiveInteger,
8
+ secret
9
+ } from './config-formats.js'
5
10
 
6
11
  convict.addFormat(positiveInteger)
7
12
  convict.addFormat(secret)
@@ -156,3 +161,152 @@ describe('secret', () => {
156
161
  expect(error.message).toContain(value)
157
162
  })
158
163
  })
164
+
165
+ describe('parseIsoDuration', () => {
166
+ test.each([
167
+ ['PT1S', 1000],
168
+ ['PT1M', 60000],
169
+ ['PT4H', 14400000],
170
+ ['P7D', 604800000],
171
+ ['P1DT12H30M', 131400000],
172
+ ['PT0.5S', 500]
173
+ ])('parses %s to %d ms', (value, expected) => {
174
+ // Act
175
+ const result = parseIsoDuration(value)
176
+
177
+ // Assert
178
+ expect(result).toBe(expected)
179
+ })
180
+
181
+ test.each(['', 'P', 'PT', 'P1W', 'P1M', 'P1Y', '4h', '14400000', 'abc'])(
182
+ 'returns undefined for %j',
183
+ (value) => {
184
+ // Act
185
+ const result = parseIsoDuration(value)
186
+
187
+ // Assert
188
+ expect(result).toBeUndefined()
189
+ }
190
+ )
191
+ })
192
+
193
+ describe('duration', () => {
194
+ const sessionTtl = duration({ max: 'P7D' })
195
+ convict.addFormat(sessionTtl)
196
+ convict.addFormat(duration())
197
+
198
+ function createDurationConfig(value, format = sessionTtl.name) {
199
+ return convict({
200
+ ttl: { format, default: 1000, env: 'TEST_TTL' }
201
+ }).load({ ttl: value })
202
+ }
203
+
204
+ test('names the format after its maximum', () => {
205
+ // Assert
206
+ expect(sessionTtl.name).toBe('duration-max-P7D')
207
+ expect(duration({ max: ' P7D ' }).name).toBe('duration-max-P7D')
208
+ expect(duration().name).toBe('duration')
209
+ })
210
+
211
+ test('rejects a max that is not an ISO 8601 duration', () => {
212
+ // Act
213
+ let error
214
+ try {
215
+ duration({ max: '7 days' })
216
+ } catch (e) {
217
+ error = e
218
+ }
219
+
220
+ // Assert
221
+ expect(error.message).toBe('max must be an ISO 8601 duration, got 7 days')
222
+ })
223
+
224
+ test.each([
225
+ ['PT4H', 14400000],
226
+ ['P7D', 604800000],
227
+ ['14400000', 14400000],
228
+ [14400000, 14400000]
229
+ ])('accepts %j as %d ms', (value, expected) => {
230
+ // Act
231
+ let config, error
232
+ try {
233
+ config = createDurationConfig(value)
234
+ config.validate({ allowed: 'strict' })
235
+ } catch (e) {
236
+ error = e
237
+ }
238
+
239
+ // Assert
240
+ expect(error).toBeUndefined()
241
+ expect(config.get('ttl')).toBe(expected)
242
+ })
243
+
244
+ test('coerces an ISO 8601 duration from an environment variable', () => {
245
+ // Arrange
246
+ process.env.TEST_TTL = 'PT4H'
247
+
248
+ // Act
249
+ let config, error
250
+ try {
251
+ config = convict({
252
+ ttl: { format: sessionTtl.name, default: 1000, env: 'TEST_TTL' }
253
+ })
254
+ config.validate({ allowed: 'strict' })
255
+ } catch (e) {
256
+ error = e
257
+ } finally {
258
+ delete process.env.TEST_TTL
259
+ }
260
+
261
+ // Assert
262
+ expect(error).toBeUndefined()
263
+ expect(config.get('ttl')).toBe(14400000)
264
+ })
265
+
266
+ test.each(['P7DT1S', 'P8D', 604800001])(
267
+ 'rejects %j as over the maximum',
268
+ (value) => {
269
+ // Act
270
+ let error
271
+ try {
272
+ createDurationConfig(value).validate({ allowed: 'strict' })
273
+ } catch (e) {
274
+ error = e
275
+ }
276
+
277
+ // Assert
278
+ expect(error.message).toContain('ttl: must be no more than P7D')
279
+ }
280
+ )
281
+
282
+ test('accepts any length when no maximum is given', () => {
283
+ // Act
284
+ let error
285
+ try {
286
+ createDurationConfig('P400D', 'duration').validate({ allowed: 'strict' })
287
+ } catch (e) {
288
+ error = e
289
+ }
290
+
291
+ // Assert
292
+ expect(error).toBeUndefined()
293
+ })
294
+
295
+ test.each([0, -1, 1.5, '0', 'P1W', '4h', 'abc', '', null, true])(
296
+ 'rejects %j naming the setting',
297
+ (value) => {
298
+ // Act
299
+ let error
300
+ try {
301
+ createDurationConfig(value).validate({ allowed: 'strict' })
302
+ } catch (e) {
303
+ error = e
304
+ }
305
+
306
+ // Assert
307
+ expect(error.message).toContain(
308
+ 'ttl: must be an ISO 8601 duration (e.g. PT4H) or a positive number of milliseconds'
309
+ )
310
+ }
311
+ )
312
+ })
@@ -2,6 +2,37 @@ import { Engine as CatboxRedis } from '@hapi/catbox-redis'
2
2
  import { Engine as CatboxMemory } from '@hapi/catbox-memory'
3
3
 
4
4
  import { buildRedisClient } from '../redis-client.js'
5
+ import { milliseconds } from '../duration.js'
6
+
7
+ /**
8
+ * The longest any key may live in the session store. CDP requires session
9
+ * data to expire within 7 days; this is enforced on every write so that no
10
+ * cache policy, however configured, can exceed it.
11
+ */
12
+ export const MAX_CACHE_TTL = milliseconds.oneWeek
13
+
14
+ /**
15
+ * Wraps a catbox engine so that every write's ttl is capped at `maxTtl`.
16
+ *
17
+ * @template {new (...args: any[]) => { set: (key: object, value: unknown, ttl: number) => Promise<void> }} T
18
+ * @param {T} EngineClass
19
+ * @param {number} maxTtl milliseconds
20
+ * @returns {T}
21
+ */
22
+ export function withMaxTtl(EngineClass, maxTtl) {
23
+ return class extends EngineClass {
24
+ /**
25
+ * @param {object} key
26
+ * @param {unknown} value
27
+ * @param {number} ttl milliseconds; a missing ttl gets the maximum
28
+ * @returns {Promise<void>}
29
+ */
30
+ set(key, value, ttl) {
31
+ const cappedTtl = typeof ttl === 'number' ? Math.min(ttl, maxTtl) : maxTtl
32
+ return super.set(key, value, cappedTtl)
33
+ }
34
+ }
35
+ }
5
36
 
6
37
  /**
7
38
  * @param {{ engine: string, config: object, logger: object, buildRedisClientFn?: Function, CatboxRedisClass?: Function, CatboxMemoryClass?: Function }} options
@@ -21,7 +52,8 @@ export function getCacheEngine({
21
52
  redisConfig: config.get('redis'),
22
53
  logger
23
54
  })
24
- return new CatboxRedisClass({ client: redisClient })
55
+ const CappedRedis = withMaxTtl(CatboxRedisClass, MAX_CACHE_TTL)
56
+ return new CappedRedis({ client: redisClient })
25
57
  }
26
58
 
27
59
  if (config.get('isProduction')) {
@@ -31,5 +63,6 @@ export function getCacheEngine({
31
63
  }
32
64
 
33
65
  logger.info('Using Catbox Memory session cache')
34
- return new CatboxMemoryClass()
66
+ const CappedMemory = withMaxTtl(CatboxMemoryClass, MAX_CACHE_TTL)
67
+ return new CappedMemory()
35
68
  }
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { test, vi } from 'vitest'
3
3
 
4
- import { getCacheEngine } from './cache-engine.js'
4
+ import { getCacheEngine, MAX_CACHE_TTL } from './cache-engine.js'
5
5
 
6
6
  test('creates a Redis cache engine with the configured client', () => {
7
7
  const client = { connected: true }
@@ -59,3 +59,50 @@ test('uses memory without warning outside production', () => {
59
59
 
60
60
  assert.equal(logger.error.mock.calls.length, 0)
61
61
  })
62
+
63
+ test('caps the ttl of every Redis write at one week', async () => {
64
+ const set = vi.fn()
65
+ class RedisEngine {
66
+ set(...args) {
67
+ return set(...args)
68
+ }
69
+ }
70
+ const logger = { info: vi.fn(), error: vi.fn() }
71
+ const config = { get: vi.fn(() => ({ host: 'redis' })) }
72
+ const engine = getCacheEngine({
73
+ engine: 'redis',
74
+ config,
75
+ logger,
76
+ buildRedisClientFn: vi.fn(() => ({})),
77
+ CatboxRedisClass: RedisEngine
78
+ })
79
+
80
+ await engine.set({ id: 'a' }, 'value', MAX_CACHE_TTL + 1)
81
+ await engine.set({ id: 'b' }, 'value', 1000)
82
+ await engine.set({ id: 'c' }, 'value', undefined)
83
+
84
+ assert.deepEqual(set.mock.calls[0], [{ id: 'a' }, 'value', MAX_CACHE_TTL])
85
+ assert.deepEqual(set.mock.calls[1], [{ id: 'b' }, 'value', 1000])
86
+ assert.deepEqual(set.mock.calls[2], [{ id: 'c' }, 'value', MAX_CACHE_TTL])
87
+ })
88
+
89
+ test('caps the ttl of every memory write at one week', async () => {
90
+ const set = vi.fn()
91
+ class MemoryEngine {
92
+ set(...args) {
93
+ return set(...args)
94
+ }
95
+ }
96
+ const logger = { info: vi.fn(), error: vi.fn() }
97
+ const config = { get: vi.fn(() => false) }
98
+ const engine = getCacheEngine({
99
+ engine: 'memory',
100
+ config,
101
+ logger,
102
+ CatboxMemoryClass: MemoryEngine
103
+ })
104
+
105
+ await engine.set({ id: 'a' }, 'value', MAX_CACHE_TTL * 2)
106
+
107
+ assert.deepEqual(set.mock.calls[0], [{ id: 'a' }, 'value', MAX_CACHE_TTL])
108
+ })