@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 +14 -2
- package/package.json +1 -1
- package/src/config-formats.js +87 -0
- package/src/config-formats.test.js +155 -1
- package/src/session-cache/cache-engine.js +35 -2
- package/src/session-cache/cache-engine.test.js +48 -1
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
package/src/config-formats.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|