@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.
@@ -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. Checks `infrastructure:health:queue:ready` key every 2 seconds
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 (default: REDIS_URL env or redis://api_node_cache:6379)
30
- * @param {number} [options.maxWait] - Maximum wait time in ms (default: 60000 = 1 minute)
31
- * @param {number} [options.checkInterval] - Check interval in ms (default: 2000 = 2 seconds)
32
- * @param {Object} [options.logger] - Logger instance (default: console)
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 = options.logger;
41
- if (!logger) {
42
- throw new Error('[service-common][waitForHealthCheckQueueReady] Missing dependency - logger is required (no console fallback).');
43
- }
44
-
45
- const log = (msg) => {
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
- log(`[HealthCheckQueueReady] Waiting for health check queue to be ready...`);
64
- log(`[HealthCheckQueueReady] Redis URL: ${redisUrl}`);
65
- log(`[HealthCheckQueueReady] Max wait: ${maxWait}ms, Check interval: ${checkInterval}ms`);
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
- log('[HealthCheckQueueReady] Connected to Redis');
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
- log(`[HealthCheckQueueReady] Health check queue is ready (took ${elapsed}ms, ${attemptCount} attempts)`);
98
+ logger.info(`✓ Health check queue is ready (took ${elapsed}ms, ${attemptCount} attempts)`);
97
99
  return true;
98
100
  }
99
101
 
100
- log(`[HealthCheckQueueReady] Attempt ${attemptCount}: Queue not ready yet (current status: ${queueReady || 'unknown'}). Waiting ${checkInterval}ms...`);
102
+ logger.info(`Attempt ${attemptCount}: Queue not ready yet (current status: ${queueReady || 'unknown'}). Waiting ${checkInterval}ms...`);
101
103
 
102
104
  } catch (error) {
103
- log(`[HealthCheckQueueReady] Attempt ${attemptCount}: Failed to get queue ready status from Redis (${error.message}). Waiting ${checkInterval}ms...`);
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 (${elapsed}ms elapsed, ${attemptCount} attempts). ` +
112
- `Check Redis key 'infrastructure:health:queue:ready' and ensure Registry has started.`
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
- log('[HealthCheckQueueReady] Disconnected from Redis.');
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. Checks `infrastructure:health:all` key every 5 seconds
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 (default: REDIS_URL env or redis://api_node_cache:6379)
38
- * @param {number} [options.maxWait] - Maximum wait time in ms (default: INFRASTRUCTURE_HEALTH_WAIT_MAX_TIME env or 60000 = 1 minute)
39
- * @param {number} [options.checkInterval] - Check interval in ms (default: INFRASTRUCTURE_HEALTH_WAIT_CHECK_INTERVAL env or 2000 = 2 seconds)
40
- * @param {Object} [options.logger] - Logger instance (default: console)
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
- * Note: Default values can be overridden via ENV variables or options parameter.
45
- * Registry config (config.infrastructureHealth) is the source of truth for these defaults.
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 = options.logger;
52
- if (!logger) {
53
- throw new Error('[service-common][waitForInfrastructureReady] Missing dependency - logger is required (no console fallback).');
54
- }
55
-
56
- // Helper to log messages (compatible with both console and winston)
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
- log('[InfrastructureReady] Waiting for all infrastructure services to be ready...');
79
- log(`[InfrastructureReady] Redis URL: ${redisUrl}`);
80
- log(`[InfrastructureReady] Max wait: ${maxWait}ms, Check interval: ${checkInterval}ms`);
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
- log('[InfrastructureReady] Connected to Redis');
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(`Redis get() operation timeout after ${REDIS_OPERATION_TIMEOUT}ms`));
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
- const allHealthy = await Promise.race([getPromise, getTimeoutPromise]);
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
- log(`[InfrastructureReady] All infrastructure services are ready (took ${elapsed}ms, ${attemptCount} attempts)`);
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('timeout')), REDIS_OPERATION_TIMEOUT);
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
- const status = await Promise.race([statusPromise, statusTimeoutPromise]);
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
- log(`[InfrastructureReady] Could not get status for ${serviceName}: ${statusError.message}`);
159
+ logger.warn(`Could not get status for ${serviceName}: ${statusError.message}`);
138
160
  }
139
161
  }
140
162
  if (Object.keys(statuses).length > 0) {
141
- log(`[InfrastructureReady] Individual service status: ${JSON.stringify(statuses, null, 2)}`);
163
+ logger.info(`Individual service status: ${JSON.stringify(statuses, null, 2)}`);
142
164
  }
143
165
 
144
166
  return true;
145
167
  }
146
168
 
147
- log(`[InfrastructureReady] Attempt ${attemptCount}: Not all services ready (current status: ${allHealthy || 'unknown'}). Waiting ${checkInterval}ms...`);
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
- log(`[InfrastructureReady] Attempt ${attemptCount}: Failed to get infrastructure health from Redis (${errorMsg}). Waiting ${checkInterval}ms...`);
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(`[InfrastructureReady] Redis connection lost: ${errorMsg}. Cannot continue waiting for infrastructure ready.`);
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 (${elapsed}ms elapsed, ${attemptCount} attempts). ` +
165
- `Check Redis key 'infrastructure:health:all' and individual service health keys.`
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
- log('[InfrastructureReady] Disconnected from Redis.');
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() method (required)
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, redisClient }) {
22
- if (!logger || typeof logger.warn !== 'function') {
23
- throw new Error('[JWT] Logger is required - Expected object with warn() method');
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
- if (redisClient && redisClient.isOpen && decoded.person_id) {
46
- try {
47
- const rolesVersion = await redisClient.get(`${ROLES_VERSION_PREFIX}${decoded.person_id}`);
48
- if (rolesVersion) {
49
- const versionTs = parseInt(rolesVersion, 10);
50
- const tokenIat = decoded.iat * 1000;
51
- if (!isNaN(versionTs) && tokenIat < versionTs) {
52
- logger.warn('[JWT] Token stale — role changed after issuance', {
53
- person_id: decoded.person_id,
54
- token_iat: new Date(tokenIat).toISOString(),
55
- roles_changed: new Date(versionTs).toISOString()
56
- });
57
- return res.status(401).json({
58
- error: 'Token stale your roles have changed, please refresh your access token',
59
- code: 'TOKEN_STALE'
60
- });
61
- }
62
- }
63
- } catch (redisErr) {
64
- logger.warn('[JWT] Redis role version check failed (non-blocking)', { error: redisErr.message });
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