@onlineapps/service-common 2.0.0 → 3.0.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/CHANGELOG.md +540 -0
- package/README.md +282 -22
- package/package.json +10 -6
- package/src/config.js +13 -1
- package/src/defaults.js +28 -1
- package/src/index.js +12 -27
- package/src/infrastructure/prefixedLogger.js +49 -0
- package/src/infrastructure/waitForHealthCheckQueueReady.js +47 -44
- package/src/infrastructure/waitForInfrastructureReady.js +88 -60
- package/src/jwt/createJwtValidator.js +159 -29
- package/src/jwt/verifyAccessToken.js +30 -11
- package/src/redactUrl.js +58 -0
- package/src/redisClient.js +175 -37
- package/src/registryReader.js +26 -42
- package/src/reporting/monitoringFallbackEmail.js +163 -28
- package/src/runtime-config.js +137 -29
- package/src/errors/BusinessError.js +0 -118
- package/src/errors/errorMiddleware.js +0 -112
- package/src/errors/index.js +0 -29
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { connectRedis, buildRedisUrl } = require('../redisClient');
|
|
4
|
+
const { createPrefixedLogger } = require('./prefixedLogger');
|
|
5
|
+
const { redactUrl } = require('../redactUrl');
|
|
4
6
|
const runtimeCfg = require('../config');
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -14,11 +16,12 @@ const runtimeCfg = require('../config');
|
|
|
14
16
|
*
|
|
15
17
|
* **How it works:**
|
|
16
18
|
* 1. Connects to Redis
|
|
17
|
-
* 2.
|
|
19
|
+
* 2. Polls the `infrastructure:health:queue:ready` key at the resolved check
|
|
20
|
+
* interval (see the `@param` notes below for where that value comes from)
|
|
18
21
|
* 3. If key is `"true"` → queue is ready → return success
|
|
19
22
|
* 4. If key is missing → wait and retry
|
|
20
23
|
* 5. If timeout reached → throw error
|
|
21
|
-
*
|
|
24
|
+
*
|
|
22
25
|
* **Usage:**
|
|
23
26
|
* - Infrastructure services: Wait before starting health check publisher
|
|
24
27
|
*/
|
|
@@ -26,62 +29,61 @@ const runtimeCfg = require('../config');
|
|
|
26
29
|
/**
|
|
27
30
|
* Wait for health check queue to be ready
|
|
28
31
|
* @param {Object} options - Options
|
|
29
|
-
* @param {string} [options.redisUrl] - Redis URL
|
|
30
|
-
*
|
|
31
|
-
* @param {number} [options.
|
|
32
|
-
*
|
|
32
|
+
* @param {string} [options.redisUrl] - Redis URL. No default: `redisUrl` is
|
|
33
|
+
* `required: true` in ../config.js, so an unset `REDIS_URL` fails fast.
|
|
34
|
+
* @param {number} [options.maxWait] - Maximum wait time in ms
|
|
35
|
+
* (env `INFRASTRUCTURE_HEALTH_QUEUE_WAIT_MAX_TIME`, else `infrastructureHealthQueueWaitMaxTimeMs` in ../defaults.js)
|
|
36
|
+
* @param {number} [options.checkInterval] - Check interval in ms
|
|
37
|
+
* (env `INFRASTRUCTURE_HEALTH_QUEUE_WAIT_CHECK_INTERVAL`, else `infrastructureHealthQueueWaitCheckIntervalMs` in ../defaults.js)
|
|
38
|
+
* @param {Object} options.logger - Logger implementing the four-method contract
|
|
39
|
+
* (`info`, `warn`, `error`, `debug` — `@onlineapps/logger-contract`). Required, validated
|
|
40
|
+
* before anything is contacted; no console fallback and no shape sniffing.
|
|
33
41
|
* @returns {Promise<boolean>} - True if queue is ready
|
|
34
42
|
* @throws {Error} - If timeout is reached
|
|
43
|
+
*
|
|
44
|
+
* Resolution order is the one @onlineapps/runtime-config applies to every key in
|
|
45
|
+
* ../config.js: explicit option → environment variable → module-owned default in
|
|
46
|
+
* ../defaults.js — the `runtimeCfg.get()` calls that open the function are the
|
|
47
|
+
* whole mechanism.
|
|
48
|
+
* This note used to promise a `redis://api_node_cache:6379` fallback for an unset
|
|
49
|
+
* `REDIS_URL`; no such default exists on this path — buildRedisUrl() states "No
|
|
50
|
+
* topology defaults (FAIL-FAST)" (../redisClient.js) and the key is `required`,
|
|
51
|
+
* so the documented fallback would have hidden the fail-fast it contradicts.
|
|
52
|
+
*
|
|
53
|
+
* The numbers are deliberately not repeated here; ../defaults.js owns them, and a
|
|
54
|
+
* copy in a comment only rots (doc-code-binding.md §1).
|
|
35
55
|
*/
|
|
36
56
|
async function waitForHealthCheckQueueReady(options = {}) {
|
|
37
57
|
const redisUrl = buildRedisUrl({ redisUrl: options.redisUrl });
|
|
38
58
|
const maxWait = runtimeCfg.get('infrastructureHealthQueueWaitMaxTimeMs', options.maxWait);
|
|
39
59
|
const checkInterval = runtimeCfg.get('infrastructureHealthQueueWaitCheckIntervalMs', options.checkInterval);
|
|
40
|
-
const logger =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (logger && typeof logger.info === 'function') {
|
|
47
|
-
logger.info(msg);
|
|
48
|
-
} else if (logger && typeof logger.log === 'function') {
|
|
49
|
-
logger.log(msg);
|
|
50
|
-
} else if (logger && typeof logger === 'function') {
|
|
51
|
-
logger(msg);
|
|
52
|
-
} else {
|
|
53
|
-
throw new Error(
|
|
54
|
-
'[service-common][waitForHealthCheckQueueReady] Invalid logger - Expected logger.info(), logger.log(), or function logger(message).'
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
};
|
|
60
|
+
const logger = createPrefixedLogger(
|
|
61
|
+
'waitForHealthCheckQueueReady',
|
|
62
|
+
'[HealthCheckQueueReady]',
|
|
63
|
+
options.logger,
|
|
64
|
+
'the wait for the health-check queue is visible while it blocks'
|
|
65
|
+
);
|
|
58
66
|
|
|
59
67
|
let redis = null;
|
|
60
68
|
const startTime = Date.now();
|
|
61
69
|
let attemptCount = 0;
|
|
62
70
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
logger.info('Waiting for health check queue to be ready...');
|
|
72
|
+
// The URL carries the Redis credential (one rail, INFRA lead 2026-09-10);
|
|
73
|
+
// these lines go to Loki, so only the endpoint may appear.
|
|
74
|
+
logger.info(`Redis URL: ${redactUrl(redisUrl)}`);
|
|
75
|
+
logger.info(`Max wait: ${maxWait}ms, Check interval: ${checkInterval}ms`);
|
|
66
76
|
|
|
67
77
|
try {
|
|
68
78
|
// Connect to Redis using shared helper (consistent config + fail-fast behaviour)
|
|
79
|
+
// The same logger goes on, so what connectRedis writes carries this wait's mark.
|
|
69
80
|
redis = await connectRedis({
|
|
70
81
|
purpose: 'wait-for-health-check-queue-ready',
|
|
71
|
-
logger
|
|
72
|
-
info: (message, meta = {}) => {
|
|
73
|
-
const suffix = meta && meta.url ? ` (${meta.url})` : '';
|
|
74
|
-
log(`[HealthCheckQueueReady] ${message}${suffix}`);
|
|
75
|
-
},
|
|
76
|
-
error: (message, meta = {}) => {
|
|
77
|
-
const detail = meta && meta.error ? ` ${meta.error}` : '';
|
|
78
|
-
log(`[HealthCheckQueueReady] ${message}${detail}`);
|
|
79
|
-
}
|
|
80
|
-
},
|
|
82
|
+
logger,
|
|
81
83
|
timeoutMs: 10000,
|
|
82
84
|
redisUrl
|
|
83
85
|
});
|
|
84
|
-
|
|
86
|
+
logger.info('Connected to Redis');
|
|
85
87
|
|
|
86
88
|
while (Date.now() - startTime < maxWait) {
|
|
87
89
|
attemptCount++;
|
|
@@ -93,14 +95,14 @@ async function waitForHealthCheckQueueReady(options = {}) {
|
|
|
93
95
|
if (queueReady === 'true') {
|
|
94
96
|
// Queue is ready, we can proceed
|
|
95
97
|
const elapsed = Date.now() - startTime;
|
|
96
|
-
|
|
98
|
+
logger.info(`✓ Health check queue is ready (took ${elapsed}ms, ${attemptCount} attempts)`);
|
|
97
99
|
return true;
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
|
|
102
|
+
logger.info(`Attempt ${attemptCount}: Queue not ready yet (current status: ${queueReady || 'unknown'}). Waiting ${checkInterval}ms...`);
|
|
101
103
|
|
|
102
104
|
} catch (error) {
|
|
103
|
-
|
|
105
|
+
logger.warn(`Attempt ${attemptCount}: Failed to get queue ready status from Redis (${error.message}). Waiting ${checkInterval}ms...`);
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
|
@@ -108,13 +110,14 @@ async function waitForHealthCheckQueueReady(options = {}) {
|
|
|
108
110
|
|
|
109
111
|
const elapsed = Date.now() - startTime;
|
|
110
112
|
throw new Error(
|
|
111
|
-
`Health check queue not ready within ${maxWait}ms
|
|
112
|
-
`
|
|
113
|
+
`[HealthCheckQueueReady] Health check queue not ready within ${maxWait}ms `
|
|
114
|
+
+ `(${elapsed}ms elapsed, ${attemptCount} attempts) - the readiness flag never appeared. `
|
|
115
|
+
+ "Fix: check the Redis key 'infrastructure:health:queue:ready' and that the Registry has started."
|
|
113
116
|
);
|
|
114
117
|
} finally {
|
|
115
118
|
if (redis && redis.isReady) {
|
|
116
119
|
await redis.quit();
|
|
117
|
-
|
|
120
|
+
logger.info('Disconnected from Redis.');
|
|
118
121
|
}
|
|
119
122
|
}
|
|
120
123
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { connectRedis, buildRedisUrl } = require('../redisClient');
|
|
4
|
+
const { createPrefixedLogger } = require('./prefixedLogger');
|
|
5
|
+
const { redactUrl } = require('../redactUrl');
|
|
4
6
|
const runtimeCfg = require('../config');
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -14,7 +16,8 @@ const runtimeCfg = require('../config');
|
|
|
14
16
|
*
|
|
15
17
|
* **How it works:**
|
|
16
18
|
* 1. Connects to Redis (where Registry stores infrastructure health status)
|
|
17
|
-
* 2.
|
|
19
|
+
* 2. Polls the `infrastructure:health:all` key at the resolved check interval
|
|
20
|
+
* (see the `@param` notes below for where that value comes from)
|
|
18
21
|
* 3. If key is `"true"` → all infrastructure services are UP → return success
|
|
19
22
|
* 4. If key is `"false"` or missing → wait and retry
|
|
20
23
|
* 5. If timeout reached → throw error
|
|
@@ -34,69 +37,62 @@ const runtimeCfg = require('../config');
|
|
|
34
37
|
/**
|
|
35
38
|
* Wait for all infrastructure services to be ready
|
|
36
39
|
* @param {Object} options - Options
|
|
37
|
-
* @param {string} [options.redisUrl] - Redis URL
|
|
38
|
-
*
|
|
39
|
-
* @param {number} [options.
|
|
40
|
-
*
|
|
40
|
+
* @param {string} [options.redisUrl] - Redis URL. No default: `redisUrl` is
|
|
41
|
+
* `required: true` in ../config.js, so an unset `REDIS_URL` fails fast.
|
|
42
|
+
* @param {number} [options.maxWait] - Maximum wait time in ms
|
|
43
|
+
* (env `INFRASTRUCTURE_HEALTH_WAIT_MAX_TIME`, else `infrastructureHealthWaitMaxTimeMs` in ../defaults.js)
|
|
44
|
+
* @param {number} [options.checkInterval] - Check interval in ms
|
|
45
|
+
* (env `INFRASTRUCTURE_HEALTH_WAIT_CHECK_INTERVAL`, else `infrastructureHealthWaitCheckIntervalMs` in ../defaults.js)
|
|
46
|
+
* @param {Object} options.logger - Logger implementing the four-method contract
|
|
47
|
+
* (`info`, `warn`, `error`, `debug` — `@onlineapps/logger-contract`). Required, validated
|
|
48
|
+
* before anything is contacted; no console fallback and no shape sniffing.
|
|
41
49
|
* @returns {Promise<boolean>} - True if all infrastructure services are ready
|
|
42
50
|
* @throws {Error} - If timeout is reached
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
51
|
+
*
|
|
52
|
+
* Resolution order is the one @onlineapps/runtime-config applies to every key in
|
|
53
|
+
* ../config.js: explicit option → environment variable → module-owned default in
|
|
54
|
+
* ../defaults.js — the `runtimeCfg.get()` calls that open the function are the
|
|
55
|
+
* whole mechanism.
|
|
56
|
+
* This note used to send readers to "Registry config (config.infrastructureHealth)"
|
|
57
|
+
* as the source of truth for these defaults; no line on this path reads anything
|
|
58
|
+
* from the Registry, and the ownership runs the other way round — ../defaults.js
|
|
59
|
+
* owns the values, and services that expose them re-declare what it owns.
|
|
60
|
+
*
|
|
61
|
+
* The numbers are deliberately not repeated here; ../defaults.js owns them, and a
|
|
62
|
+
* copy in a comment only rots (doc-code-binding.md §1).
|
|
46
63
|
*/
|
|
47
64
|
async function waitForInfrastructureReady(options = {}) {
|
|
48
65
|
const redisUrl = buildRedisUrl({ redisUrl: options.redisUrl });
|
|
49
66
|
const maxWait = runtimeCfg.get('infrastructureHealthWaitMaxTimeMs', options.maxWait);
|
|
50
67
|
const checkInterval = runtimeCfg.get('infrastructureHealthWaitCheckIntervalMs', options.checkInterval);
|
|
51
|
-
const logger =
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const log = (messageStr) => {
|
|
58
|
-
if (logger && typeof logger.log === 'function') {
|
|
59
|
-
// Winston-style logger
|
|
60
|
-
logger.log({ message: messageStr, level: 'info' });
|
|
61
|
-
} else if (logger && typeof logger.info === 'function') {
|
|
62
|
-
// Standard logger
|
|
63
|
-
logger.info(messageStr);
|
|
64
|
-
} else if (logger && typeof logger === 'function') {
|
|
65
|
-
// console.log or similar
|
|
66
|
-
logger(messageStr);
|
|
67
|
-
} else {
|
|
68
|
-
throw new Error(
|
|
69
|
-
'[service-common][waitForInfrastructureReady] Invalid logger - Expected logger.info(), logger.log(), or function logger(message).'
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
};
|
|
68
|
+
const logger = createPrefixedLogger(
|
|
69
|
+
'waitForInfrastructureReady',
|
|
70
|
+
'[InfrastructureReady]',
|
|
71
|
+
options.logger,
|
|
72
|
+
'the wait that blocks the boot is visible while it blocks'
|
|
73
|
+
);
|
|
73
74
|
|
|
74
75
|
const startTime = Date.now();
|
|
75
76
|
let attemptCount = 0;
|
|
76
77
|
let redis = null;
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
logger.info('Waiting for all infrastructure services to be ready...');
|
|
80
|
+
// The URL carries the Redis credential (one rail, INFRA lead 2026-09-10);
|
|
81
|
+
// these lines go to Loki, so only the endpoint may appear.
|
|
82
|
+
logger.info(`Redis URL: ${redactUrl(redisUrl)}`);
|
|
83
|
+
logger.info(`Max wait: ${maxWait}ms, Check interval: ${checkInterval}ms`);
|
|
81
84
|
|
|
82
85
|
try {
|
|
83
86
|
// Connect to Redis with timeout using shared helper (fail-fast, no infinite reconnect loops)
|
|
87
|
+
// The same logger goes on, so what connectRedis writes carries this wait's mark
|
|
88
|
+
// without connectRedis knowing anything about it.
|
|
84
89
|
redis = await connectRedis({
|
|
85
90
|
purpose: 'wait-for-infrastructure-ready',
|
|
86
|
-
logger
|
|
87
|
-
error: (message, meta = {}) => {
|
|
88
|
-
const msg = meta && meta.error ? `${message} ${meta.error}` : message;
|
|
89
|
-
log(`[InfrastructureReady] ${msg}`);
|
|
90
|
-
},
|
|
91
|
-
info: (message, meta = {}) => {
|
|
92
|
-
const suffix = meta && meta.url ? ` (${meta.url})` : '';
|
|
93
|
-
log(`[InfrastructureReady] ${message}${suffix}`);
|
|
94
|
-
}
|
|
95
|
-
},
|
|
91
|
+
logger,
|
|
96
92
|
timeoutMs: 10000,
|
|
97
93
|
redisUrl
|
|
98
94
|
});
|
|
99
|
-
|
|
95
|
+
logger.info('Connected to Redis');
|
|
100
96
|
|
|
101
97
|
while (Date.now() - startTime < maxWait) {
|
|
102
98
|
attemptCount++;
|
|
@@ -105,18 +101,32 @@ async function waitForInfrastructureReady(options = {}) {
|
|
|
105
101
|
// Check Redis key: infrastructure:health:all with timeout to prevent hanging
|
|
106
102
|
const REDIS_OPERATION_TIMEOUT = 5000; // 5 seconds per operation
|
|
107
103
|
const getPromise = redis.get('infrastructure:health:all');
|
|
104
|
+
let getTimeoutHandle;
|
|
108
105
|
const getTimeoutPromise = new Promise((_, reject) => {
|
|
109
|
-
setTimeout(() => {
|
|
110
|
-
reject(new Error(
|
|
106
|
+
getTimeoutHandle = setTimeout(() => {
|
|
107
|
+
reject(new Error(
|
|
108
|
+
`[InfrastructureReady] Redis get() operation timeout after ${REDIS_OPERATION_TIMEOUT}ms - `
|
|
109
|
+
+ "the key 'infrastructure:health:all' was never read. "
|
|
110
|
+
+ 'Fix: check that Redis is reachable and responsive.'
|
|
111
|
+
));
|
|
111
112
|
}, REDIS_OPERATION_TIMEOUT);
|
|
112
113
|
});
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
|
|
115
|
+
let allHealthy;
|
|
116
|
+
try {
|
|
117
|
+
allHealthy = await Promise.race([getPromise, getTimeoutPromise]);
|
|
118
|
+
} finally {
|
|
119
|
+
// Promise.race settles on the first arm; the loser keeps running. Without
|
|
120
|
+
// this, every poll left a five-second timer holding the event loop after
|
|
121
|
+
// the wait had already answered — and this wait polls in a loop.
|
|
122
|
+
// Same defect, same fix as connectRedis (../redisClient.js).
|
|
123
|
+
clearTimeout(getTimeoutHandle);
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
if (allHealthy === 'true') {
|
|
117
127
|
// All services are UP, we can proceed
|
|
118
128
|
const elapsed = Date.now() - startTime;
|
|
119
|
-
|
|
129
|
+
logger.info(`✓ All infrastructure services are ready (took ${elapsed}ms, ${attemptCount} attempts)`);
|
|
120
130
|
|
|
121
131
|
// Optionally log individual service status
|
|
122
132
|
// Note: Service names should match config.infrastructureServices in Registry
|
|
@@ -125,34 +135,51 @@ async function waitForInfrastructureReady(options = {}) {
|
|
|
125
135
|
for (const serviceName of serviceKeys) {
|
|
126
136
|
try {
|
|
127
137
|
const statusPromise = redis.get(`infrastructure:health:${serviceName}`);
|
|
138
|
+
let statusTimeoutHandle;
|
|
128
139
|
const statusTimeoutPromise = new Promise((_, reject) => {
|
|
129
|
-
setTimeout(() => reject(new Error(
|
|
140
|
+
statusTimeoutHandle = setTimeout(() => reject(new Error(
|
|
141
|
+
`[InfrastructureReady] Redis get() timeout after ${REDIS_OPERATION_TIMEOUT}ms - `
|
|
142
|
+
+ `the status key of ${serviceName} was never read. `
|
|
143
|
+
+ 'Fix: check that Redis is reachable and responsive.'
|
|
144
|
+
)), REDIS_OPERATION_TIMEOUT);
|
|
130
145
|
});
|
|
131
|
-
|
|
146
|
+
|
|
147
|
+
let status;
|
|
148
|
+
try {
|
|
149
|
+
status = await Promise.race([statusPromise, statusTimeoutPromise]);
|
|
150
|
+
} finally {
|
|
151
|
+
clearTimeout(statusTimeoutHandle);
|
|
152
|
+
}
|
|
153
|
+
|
|
132
154
|
if (status) {
|
|
133
155
|
statuses[serviceName] = JSON.parse(status);
|
|
134
156
|
}
|
|
135
157
|
} catch (statusError) {
|
|
136
158
|
// Ignore individual service status errors
|
|
137
|
-
|
|
159
|
+
logger.warn(`Could not get status for ${serviceName}: ${statusError.message}`);
|
|
138
160
|
}
|
|
139
161
|
}
|
|
140
162
|
if (Object.keys(statuses).length > 0) {
|
|
141
|
-
|
|
163
|
+
logger.info(`Individual service status: ${JSON.stringify(statuses, null, 2)}`);
|
|
142
164
|
}
|
|
143
165
|
|
|
144
166
|
return true;
|
|
145
167
|
}
|
|
146
168
|
|
|
147
|
-
|
|
169
|
+
logger.info(`Attempt ${attemptCount}: Not all services ready (current status: ${allHealthy || 'unknown'}). Waiting ${checkInterval}ms...`);
|
|
148
170
|
|
|
149
171
|
} catch (error) {
|
|
150
172
|
const errorMsg = error.message || String(error);
|
|
151
|
-
|
|
173
|
+
logger.warn(`Attempt ${attemptCount}: Failed to get infrastructure health from Redis (${errorMsg}). Waiting ${checkInterval}ms...`);
|
|
152
174
|
|
|
153
175
|
// If Redis connection is lost, throw error immediately instead of retrying
|
|
154
176
|
if (errorMsg.includes('timeout') || errorMsg.includes('ECONNREFUSED') || errorMsg.includes('ENOTFOUND')) {
|
|
155
|
-
throw new Error(
|
|
177
|
+
throw new Error(
|
|
178
|
+
`[InfrastructureReady] Redis connection lost - ${errorMsg} `
|
|
179
|
+
+ '— waiting for infrastructure readiness cannot continue without Redis. '
|
|
180
|
+
+ 'Fix: restore Redis and boot again.',
|
|
181
|
+
{ cause: error }
|
|
182
|
+
);
|
|
156
183
|
}
|
|
157
184
|
}
|
|
158
185
|
|
|
@@ -161,13 +188,14 @@ async function waitForInfrastructureReady(options = {}) {
|
|
|
161
188
|
|
|
162
189
|
const elapsed = Date.now() - startTime;
|
|
163
190
|
throw new Error(
|
|
164
|
-
`Infrastructure services not ready within ${maxWait}ms
|
|
165
|
-
`
|
|
191
|
+
`[InfrastructureReady] Infrastructure services not ready within ${maxWait}ms `
|
|
192
|
+
+ `(${elapsed}ms elapsed, ${attemptCount} attempts) - the readiness flag never turned true. `
|
|
193
|
+
+ "Fix: check the Redis key 'infrastructure:health:all' and the individual service health keys."
|
|
166
194
|
);
|
|
167
195
|
} finally {
|
|
168
196
|
if (redis && redis.isReady) {
|
|
169
197
|
await redis.quit();
|
|
170
|
-
|
|
198
|
+
logger.info('Disconnected from Redis.');
|
|
171
199
|
}
|
|
172
200
|
}
|
|
173
201
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { verifyAccessToken } = require('./verifyAccessToken');
|
|
3
|
+
const { verifyAccessToken, assertSecret } = require('./verifyAccessToken');
|
|
4
4
|
|
|
5
5
|
// See: docs/standards/JWT_AUTH.md §9.1 — forced refresh on role change
|
|
6
|
-
const ROLES_VERSION_PREFIX = 'person:roles_version:';
|
|
6
|
+
const ROLES_VERSION_PREFIX = 'state:meta:person:roles_version:';
|
|
7
|
+
|
|
8
|
+
const ROLES_VERSION_CHECK_UNAVAILABLE = 'ROLES_VERSION_CHECK_UNAVAILABLE';
|
|
9
|
+
const TOKEN_PERSON_ID_MISSING = 'TOKEN_PERSON_ID_MISSING';
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Create Express middleware that validates JWT Bearer tokens on incoming requests.
|
|
@@ -12,15 +15,91 @@ const ROLES_VERSION_PREFIX = 'person:roles_version:';
|
|
|
12
15
|
* On failure: responds with 401 JSON.
|
|
13
16
|
* Paths listed in excludePaths are skipped (transparent pass-through).
|
|
14
17
|
*
|
|
18
|
+
* A token whose payload carries no `person_id` is refused with 401
|
|
19
|
+
* TOKEN_PERSON_ID_MISSING before the roles-version check runs: `api_auth` issues
|
|
20
|
+
* every access and refresh token with the claim, so no legitimate class of
|
|
21
|
+
* tokens lacks it, and the claim is what the check is keyed on — accepting a
|
|
22
|
+
* token without it would mean skipping the check for that request.
|
|
23
|
+
* @see docs/governance/confirmations/jwt-stale-check-fail-closed.md 002
|
|
24
|
+
*
|
|
25
|
+
* ## The roles-version check is an injected reader, not a Redis client
|
|
26
|
+
*
|
|
27
|
+
* `readRolesVersion(personId)` returns the marker the owning service wrote for
|
|
28
|
+
* that person — epoch milliseconds — or `null` when no marker exists. It is
|
|
29
|
+
* REQUIRED: the check runs for every valid token, so an instance that could not
|
|
30
|
+
* perform it is refused at construction rather than serving unverified requests
|
|
31
|
+
* (`jwt-stale-check-fail-closed` 001 § Conditions, architecture-principles.md §4).
|
|
32
|
+
*
|
|
33
|
+
* Reading is the caller's job because the shape of the store is the caller's
|
|
34
|
+
* knowledge: the validator used to test `redisClient.isOpen` and call
|
|
35
|
+
* `redisClient.get()`, which is the node-redis v4 API — a service whose client is
|
|
36
|
+
* `ioredis` could not be given the check at all (`infra/api_meta_reader`).
|
|
37
|
+
* A function has one shape and every client can implement it
|
|
38
|
+
* (architecture-principles.md §1 dependency injection, §8 explicit over implicit).
|
|
39
|
+
*
|
|
40
|
+
* The key the reader must read is the FULL, prefixed key
|
|
41
|
+
* `state:meta:person:roles_version:<person_id>` — exported here as
|
|
42
|
+
* `ROLES_VERSION_PREFIX`, so the reader composes it rather than spelling it out:
|
|
43
|
+
* the marker is a projection of the meta service state, written by biz-meta under
|
|
44
|
+
* the `state:meta:` prefix its state connector adds, and a reader that asks for
|
|
45
|
+
* the bare `person:roles_version:<id>` gets `null` for every person — which is
|
|
46
|
+
* indistinguishable from "no role change" and answers `TOKEN_STALE` never.
|
|
47
|
+
* Owner decision 2026-09-14.
|
|
48
|
+
* @see api/docs/governance/confirmations/redis-state-prefix.md 002
|
|
49
|
+
* @see api/docs/standards/redis-key-contract.md
|
|
50
|
+
*
|
|
51
|
+
* ```js
|
|
52
|
+
* const readRolesVersion = async (personId) => {
|
|
53
|
+
* const raw = await redis.get(`${ROLES_VERSION_PREFIX}${personId}`);
|
|
54
|
+
* return raw === null ? null : Number(raw);
|
|
55
|
+
* };
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* The check is fail-closed: whenever it cannot be performed — the reader rejects,
|
|
59
|
+
* or answers with anything but a finite number or `null` — the request is refused
|
|
60
|
+
* with 503, never let through unverified.
|
|
61
|
+
* @see docs/governance/confirmations/jwt-stale-check-fail-closed.md 001
|
|
62
|
+
*
|
|
63
|
+
* ## Units: the token counts seconds, the marker milliseconds
|
|
64
|
+
*
|
|
65
|
+
* `iat` is a whole number of SECONDS (RFC 7519 §4.1.6), rounded down; the marker
|
|
66
|
+
* is epoch MILLISECONDS (`redis-key-contract.md`). Compared in milliseconds, a
|
|
67
|
+
* token issued 400 ms after the role change it already reflects looks older than
|
|
68
|
+
* that change, and its holder is told to refresh a token that is current. The
|
|
69
|
+
* comparison therefore happens in the coarser unit of the two: the marker is
|
|
70
|
+
* floored to whole seconds, and a token issued in the same second as the marker
|
|
71
|
+
* is NOT stale.
|
|
72
|
+
*
|
|
73
|
+
* The signing secret is an input of the instance, resolved by the caller at boot
|
|
74
|
+
* (`requireEnv('JWT_SECRET', …, { file: 'shared.env' })`) and passed in here. The
|
|
75
|
+
* library never reads the environment: where a secret comes from is the owning
|
|
76
|
+
* service's knowledge, not a shared library's (architecture-principles.md §1, §8).
|
|
77
|
+
* An instance built without a usable secret is refused at construction (§4).
|
|
78
|
+
* @see api/docs/standards/JWT_AUTH.md § Configuration
|
|
79
|
+
*
|
|
15
80
|
* @param {object} options
|
|
16
|
-
* @param {object} options.logger - Logger with .warn()
|
|
81
|
+
* @param {object} options.logger - Logger with .warn() and .error() methods (required)
|
|
82
|
+
* @param {string} options.secret - HMAC-SHA256 signing secret, min 16 characters (required)
|
|
83
|
+
* @param {function} options.readRolesVersion - `(personId) => Promise<number|null>`
|
|
84
|
+
* reading `state:meta:person:roles_version:<person_id>` (required)
|
|
17
85
|
* @param {string[]} [options.excludePaths] - Paths to skip JWT validation
|
|
18
|
-
* @param {object} [options.redisClient] - Redis client for role version check (optional)
|
|
19
86
|
* @returns {Function} Express middleware (req, res, next)
|
|
20
87
|
*/
|
|
21
|
-
function createJwtValidator({ logger, excludePaths,
|
|
22
|
-
if (!logger || typeof logger.warn !== 'function') {
|
|
23
|
-
throw new Error('[JWT] Logger is required - Expected object with warn()
|
|
88
|
+
function createJwtValidator({ logger, secret, excludePaths, readRolesVersion }) {
|
|
89
|
+
if (!logger || typeof logger.warn !== 'function' || typeof logger.error !== 'function') {
|
|
90
|
+
throw new Error('[JWT] Logger is required - Expected object with warn() and error() methods');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
assertSecret(secret);
|
|
94
|
+
|
|
95
|
+
if (typeof readRolesVersion !== 'function') {
|
|
96
|
+
throw new Error(
|
|
97
|
+
'[JWT] Roles-version reader is required - Expected a function '
|
|
98
|
+
+ '(personId) => Promise<number|null> returning the epoch milliseconds stored at '
|
|
99
|
+
+ `${ROLES_VERSION_PREFIX}<person_id>, or null when no marker exists. `
|
|
100
|
+
+ 'Fix: pass readRolesVersion when constructing the validator — the check runs for '
|
|
101
|
+
+ 'every valid token and an instance that cannot perform it must not exist'
|
|
102
|
+
);
|
|
24
103
|
}
|
|
25
104
|
|
|
26
105
|
const excluded = new Set(excludePaths || []);
|
|
@@ -40,28 +119,79 @@ function createJwtValidator({ logger, excludePaths, redisClient }) {
|
|
|
40
119
|
const token = authHeader.slice(7);
|
|
41
120
|
|
|
42
121
|
try {
|
|
43
|
-
const decoded = verifyAccessToken(token);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
122
|
+
const decoded = verifyAccessToken(token, secret);
|
|
123
|
+
|
|
124
|
+
// Fail-fast on the payload shape, ahead of any check keyed on it: an
|
|
125
|
+
// absent (or null, or empty) person_id used to make the roles-version
|
|
126
|
+
// check silently skippable, which is an implicit lenient branch
|
|
127
|
+
// (architecture-principles.md §3 no fallbacks, §8 explicit over implicit).
|
|
128
|
+
const personId = decoded.person_id;
|
|
129
|
+
if (personId === undefined || personId === null || personId === '') {
|
|
130
|
+
logger.warn('[JWT] Token rejected — payload carries no person_id', {
|
|
131
|
+
person_uuid: decoded.sub
|
|
132
|
+
});
|
|
133
|
+
return res.status(401).json({
|
|
134
|
+
error:
|
|
135
|
+
'[JWT] Token payload carries no person_id - Expected an access token with the ' +
|
|
136
|
+
'person_id claim (every token api_auth issues has one); without it the token ' +
|
|
137
|
+
`cannot be checked against ${ROLES_VERSION_PREFIX}<person_id>. ` +
|
|
138
|
+
'Fix: sign in again to obtain a current access token',
|
|
139
|
+
code: TOKEN_PERSON_ID_MISSING
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let rolesVersion;
|
|
144
|
+
try {
|
|
145
|
+
rolesVersion = await readRolesVersion(personId);
|
|
146
|
+
} catch (readErr) {
|
|
147
|
+
logger.error('[JWT] Roles-version check failed — request refused', {
|
|
148
|
+
person_id: personId,
|
|
149
|
+
error: readErr && readErr.message ? readErr.message : String(readErr)
|
|
150
|
+
});
|
|
151
|
+
return res.status(503).json({
|
|
152
|
+
error:
|
|
153
|
+
'[JWT] Roles-version check unavailable - reading ' +
|
|
154
|
+
`${ROLES_VERSION_PREFIX}<person_id> failed, so a role change cannot be ruled out. ` +
|
|
155
|
+
'Fix: restore the store that holds the marker and retry the request',
|
|
156
|
+
code: ROLES_VERSION_CHECK_UNAVAILABLE
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (rolesVersion !== null) {
|
|
161
|
+
// Anything but a finite number is a reader that cannot answer the
|
|
162
|
+
// question — the same situation as an error, so the same answer
|
|
163
|
+
// (fail-closed, confirmation 001). `undefined` included: the contract
|
|
164
|
+
// says null for "no marker", and a reader returning nothing is broken.
|
|
165
|
+
if (typeof rolesVersion !== 'number' || !Number.isFinite(rolesVersion)) {
|
|
166
|
+
logger.error('[JWT] Roles-version check failed — request refused', {
|
|
167
|
+
person_id: personId,
|
|
168
|
+
error: `readRolesVersion returned ${typeof rolesVersion}, expected number or null`
|
|
169
|
+
});
|
|
170
|
+
return res.status(503).json({
|
|
171
|
+
error:
|
|
172
|
+
'[JWT] Roles-version check unavailable - readRolesVersion answered with ' +
|
|
173
|
+
`${typeof rolesVersion}, expected the epoch milliseconds stored at ` +
|
|
174
|
+
`${ROLES_VERSION_PREFIX}<person_id> as a finite number, or null when the key ` +
|
|
175
|
+
'does not exist; a role change cannot be ruled out. ' +
|
|
176
|
+
'Fix: return Number(<stored value>) from the reader',
|
|
177
|
+
code: ROLES_VERSION_CHECK_UNAVAILABLE
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// `iat` counts whole seconds, the marker counts milliseconds: the
|
|
182
|
+
// comparison happens in the coarser unit, so a token issued in the same
|
|
183
|
+
// second as the role change is current, not stale.
|
|
184
|
+
const rolesChangedAtSec = Math.floor(rolesVersion / 1000);
|
|
185
|
+
if (decoded.iat < rolesChangedAtSec) {
|
|
186
|
+
logger.warn('[JWT] Token stale — role changed after issuance', {
|
|
187
|
+
person_id: personId,
|
|
188
|
+
token_iat: new Date(decoded.iat * 1000).toISOString(),
|
|
189
|
+
roles_changed: new Date(rolesVersion).toISOString()
|
|
190
|
+
});
|
|
191
|
+
return res.status(401).json({
|
|
192
|
+
error: 'Token stale — your roles have changed, please refresh your access token',
|
|
193
|
+
code: 'TOKEN_STALE'
|
|
194
|
+
});
|
|
65
195
|
}
|
|
66
196
|
}
|
|
67
197
|
|