@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.
@@ -6,17 +6,34 @@ const ISSUER = 'oa-auth';
6
6
  const MIN_SECRET_LENGTH = 16;
7
7
 
8
8
  /**
9
- * Resolve JWT_SECRET from environment. Fail-fast on missing/insecure value.
10
- * @returns {string}
9
+ * Validate the signing secret the caller passed in.
10
+ *
11
+ * The secret is an INPUT of this library and never something it resolves for
12
+ * itself: a library that reads `process.env.JWT_SECRET` knows where the platform
13
+ * keeps its configuration, which is exactly what principle 1 (dependencies by
14
+ * constructor) and principle 8 (explicit over implicit) forbid. The RULE about
15
+ * the value — a string of at least MIN_SECRET_LENGTH characters, the minimum
16
+ * `JWT_AUTH.md` § Configuration states — stays here, because it is a property of
17
+ * the secret rather than of where it came from.
18
+ *
19
+ * Both public entry points validate against this one function: `createJwtValidator`
20
+ * at construction and `verifyAccessToken` at method entry (principle 4,
21
+ * fail-fast), so an instance can never be built with a secret a request would
22
+ * then be refused for.
23
+ *
24
+ * @param {string} secret - HMAC-SHA256 signing secret, resolved by the caller
25
+ * @throws {Error} If the secret is absent, not a string, or shorter than the minimum
26
+ * @see api/docs/standards/JWT_AUTH.md § Configuration
11
27
  */
12
- function getJwtSecret() {
13
- const secret = process.env.JWT_SECRET;
14
- if (!secret || secret.length < MIN_SECRET_LENGTH) {
28
+ function assertSecret(secret) {
29
+ if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH) {
15
30
  throw new Error(
16
- `[JWT] Missing or insecure JWT_SECRET - Expected env var with at least ${MIN_SECRET_LENGTH} characters`
31
+ `[JWT] Missing or insecure secret - Expected a string of at least ${MIN_SECRET_LENGTH} characters, `
32
+ + 'passed in by the caller. Fix: resolve the platform key once at boot with '
33
+ + 'requireEnv("JWT_SECRET", "HMAC-SHA256 signing secret", { file: "shared.env" }) and pass the '
34
+ + 'value as verifyAccessToken(token, secret) / createJwtValidator({ secret }).'
17
35
  );
18
36
  }
19
- return secret;
20
37
  }
21
38
 
22
39
  /**
@@ -27,15 +44,17 @@ function getJwtSecret() {
27
44
  * Throws on any validation failure — callers decide how to handle.
28
45
  *
29
46
  * @param {string} token - Raw JWT string (without "Bearer " prefix)
47
+ * @param {string} secret - HMAC-SHA256 signing secret (see {@link assertSecret})
30
48
  * @returns {object} Decoded payload: { sub, person_id, email, tenants, type, iss, iat, exp }
31
- * @throws {Error} TokenExpiredError, JsonWebTokenError, or type mismatch
49
+ * @throws {Error} TokenExpiredError, JsonWebTokenError, type mismatch, or an invalid secret
32
50
  */
33
- function verifyAccessToken(token) {
51
+ function verifyAccessToken(token, secret) {
34
52
  if (!token || typeof token !== 'string') {
35
53
  throw new Error('[JWT] Token is required - Expected non-empty string');
36
54
  }
37
55
 
38
- const secret = getJwtSecret();
56
+ assertSecret(secret);
57
+
39
58
  const decoded = jwt.verify(token, secret, { issuer: ISSUER });
40
59
 
41
60
  if (decoded.type !== 'access') {
@@ -47,4 +66,4 @@ function verifyAccessToken(token) {
47
66
  return decoded;
48
67
  }
49
68
 
50
- module.exports = { verifyAccessToken, getJwtSecret, ISSUER, MIN_SECRET_LENGTH };
69
+ module.exports = { verifyAccessToken, assertSecret, ISSUER, MIN_SECRET_LENGTH };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Redaction of credentials carried in a connection URL.
5
+ *
6
+ * `REDIS_URL` is the single rail for the Redis credential (INFRA lead's decision
7
+ * 2026-09-10: one rail, no separate `REDIS_PASSWORD` in the shared env set).
8
+ * That makes every place which logs the URL verbatim a credential leak — and
9
+ * these logs go to Loki, so the leak is durable and searchable.
10
+ *
11
+ * `redactUrl` returns the same URL with the userinfo removed: what stays is the
12
+ * part a reader of the log actually needs — scheme, host, port, path.
13
+ *
14
+ * A value that is not a parseable URL is returned as a fixed placeholder rather
15
+ * than echoed: an unparseable string is exactly the case where nobody can say
16
+ * whether it holds a credential, and echoing it would be the leak this helper
17
+ * exists to prevent.
18
+ */
19
+
20
+ const UNPARSEABLE_PLACEHOLDER = '<unparseable-url>';
21
+
22
+ /**
23
+ * Strip userinfo (`user:password@`) from a connection URL.
24
+ *
25
+ * @param {string} url - connection URL, e.g. `redis://:secret@cache:6379`
26
+ * @returns {string} the URL without userinfo, e.g. `redis://cache:6379`,
27
+ * or `<unparseable-url>` when the input is not a URL.
28
+ */
29
+ function redactUrl(url) {
30
+ if (typeof url !== 'string' || url.length === 0) {
31
+ return UNPARSEABLE_PLACEHOLDER;
32
+ }
33
+ let parsed;
34
+ try {
35
+ parsed = new URL(url);
36
+ } catch (_) {
37
+ return UNPARSEABLE_PLACEHOLDER;
38
+ }
39
+ // `new URL('cache-host:6379')` SUCCEEDS — it reads `cache-host:` as the scheme
40
+ // and `6379` as an opaque path, so the value comes back with an empty host and
41
+ // no userinfo to strip. Measured 2026-09-11; without this guard such a value
42
+ // would be echoed verbatim, which is the leak this helper exists to prevent.
43
+ if (!parsed.host) {
44
+ return UNPARSEABLE_PLACEHOLDER;
45
+ }
46
+ parsed.username = '';
47
+ parsed.password = '';
48
+ // `URL.href` re-appends a trailing slash for an empty path; the URLs this is
49
+ // used on are host:port endpoints, and `redis://cache:6379/` is not the shape
50
+ // the rest of the platform writes.
51
+ const href = parsed.href;
52
+ return parsed.pathname === '/' && !url.endsWith('/') ? href.replace(/\/$/, '') : href;
53
+ }
54
+
55
+ module.exports = {
56
+ redactUrl,
57
+ UNPARSEABLE_PLACEHOLDER
58
+ };
@@ -15,6 +15,48 @@
15
15
  const { createClient } = require('redis');
16
16
  const runtimeCfg = require('./config');
17
17
  const { createRuntimeConfig } = require('@onlineapps/runtime-config');
18
+ const { assertLogger } = require('@onlineapps/logger-contract');
19
+ const { redactUrl } = require('./redactUrl');
20
+
21
+ /**
22
+ * Share of the caller's `timeoutMs` that node-redis' OWN connect attempt gets;
23
+ * the remaining fifth is the reserve the hard race below keeps for itself. The
24
+ * value is derived from the budget the caller already passes — no second knob,
25
+ * no env key, and the deadline the caller asked for stays the deadline.
26
+ *
27
+ * Why the attempt must end FIRST: `RedisSocket#disconnect()` destroys only a
28
+ * socket it has already assigned (`@redis/client` `socket.js`
29
+ * `_RedisSocket_disconnect`), so a connect still in flight survives our
30
+ * `disconnect()` and keeps its TCPWRAP handle open for node-redis' default
31
+ * 5 s. What does destroy it is node-redis' own
32
+ * `socket.setTimeout(connectTimeout, () => socket.destroy(…))`, armed in
33
+ * `_RedisSocket_createSocket` and cleared again the moment the socket connects,
34
+ * so an established connection is never touched by it.
35
+ *
36
+ * A fifth is reserve enough: measured on @redis/client 1.6.1 (2026-09-14), the
37
+ * attempt rejects 3 ms after its configured deadline (40 ms → 43.0 ms and
38
+ * 42.3 ms, 200 ms → 203.3 ms and 201.8 ms, 1000 ms → 1001.0 ms), while the
39
+ * reserve is 10 ms at the smallest timeout this package calls with (50 ms in
40
+ * the unit tier) and 3 s at the 15 s default.
41
+ */
42
+ const CONNECT_ATTEMPT_SHARE = 0.8;
43
+
44
+ /**
45
+ * Did node-redis end its own connect attempt on the deadline we gave it?
46
+ *
47
+ * It reports that as `ConnectionTimeoutError` ('Connection timeout') — a class
48
+ * it exports but never names on the instance: measured on @redis/client 1.6.1,
49
+ * `err.name` is 'Error' and only `err.constructor.name` carries the type. It is
50
+ * read off the instance rather than imported, so a test double of `redis` does
51
+ * not have to grow an export for this module to keep telling the truth about
52
+ * which deadline was hit.
53
+ *
54
+ * @param {*} err - The error the connect attempt rejected with
55
+ * @returns {boolean}
56
+ */
57
+ function isConnectDeadline(err) {
58
+ return Boolean(err) && Boolean(err.constructor) && err.constructor.name === 'ConnectionTimeoutError';
59
+ }
18
60
 
19
61
  /**
20
62
  * Build a Redis connection URL from environment.
@@ -53,87 +95,183 @@ function buildRedisUrl(options = {}) {
53
95
  * @param {Object} options
54
96
  * @param {string} [options.purpose] - Logical purpose (for log context)
55
97
  * @param {boolean} [options.forTests=false] - If true, disables automatic reconnect loops
56
- * @param {Object} [options.logger=console] - Logger with .error/.info methods
98
+ * @param {Object} options.logger - Logger with info/warn/error/debug (required)
57
99
  * @param {Object} [options.env=process.env] - Optional env override (for tests)
58
100
  * @param {Object} [options.defaults] - Optional default host/port overrides
59
- * @returns {{ client: import('redis').RedisClientType, url: string }}
101
+ * @param {number} [options.connectAttemptTimeoutMs] - Deadline for ONE node-redis
102
+ * connect attempt (`socket.connectTimeout`). Left to node-redis' own default
103
+ * when absent. Not the same thing as the overall ceiling `connectRedis` keeps:
104
+ * a reconnect strategy may spend this budget once per attempt.
105
+ * @returns {{ client: import('redis').RedisClientType, url: string, loggedUrl: string }}
106
+ * `url` is the real connection URL, credential included — it goes to the
107
+ * client. `loggedUrl` is the same endpoint with the userinfo stripped, and it
108
+ * is the ONLY one of the two that may reach a log line or an error message.
60
109
  */
61
110
  function createRedisClient(options = {}) {
62
111
  const {
63
112
  purpose = 'default',
64
113
  forTests = false,
65
- logger = console,
66
114
  env,
67
- redisUrl
115
+ redisUrl,
116
+ connectAttemptTimeoutMs
68
117
  } = options;
69
118
 
119
+ // A redis 'error' event has nowhere else to go: the emitter swallows it, and
120
+ // an unhandled one takes the process down. That is the whole reason the logger
121
+ // is a hard requirement here (confirmation 001, 002).
122
+ const log = assertLogger(
123
+ 'createRedisClient',
124
+ options.logger,
125
+ 'the Redis error event this client swallows still reaches the structured log'
126
+ );
127
+
70
128
  const url = buildRedisUrl({ env, redisUrl });
71
129
 
72
- const socketOptions = forTests
73
- ? {
74
- // In tests we typically want fail-fast behaviour, not infinite reconnect loops
75
- reconnectStrategy: false
76
- }
77
- : {};
130
+ const socketOptions = {};
131
+ if (forTests) {
132
+ // In tests we typically want fail-fast behaviour, not infinite reconnect loops
133
+ socketOptions.reconnectStrategy = false;
134
+ }
135
+ if (connectAttemptTimeoutMs !== undefined) {
136
+ // node-redis destroys the socket of an attempt that overruns this, which is
137
+ // the only thing that releases a connect still in flight. @see CONNECT_ATTEMPT_SHARE
138
+ socketOptions.connectTimeout = connectAttemptTimeoutMs;
139
+ }
78
140
 
79
141
  const client = createClient({
80
142
  url,
81
143
  socket: socketOptions
82
144
  });
83
145
 
84
- const log = logger && typeof logger.error === 'function' ? logger : console;
146
+ // The URL carries the credential; the log carries the endpoint only.
147
+ // @see ./redactUrl.js
148
+ const loggedUrl = redactUrl(url);
85
149
 
86
150
  client.on('error', (err) => {
87
- try {
88
- log.error('[Redis] Error', {
89
- purpose,
90
- url,
91
- message: err && err.message ? err.message : String(err)
92
- });
93
- } catch {
94
- // Last-resort fallback to avoid throwing in error handler
95
- // eslint-disable-next-line no-console
96
- console.error('[Redis] Error', err);
97
- }
151
+ log.error('[Redis] Error', {
152
+ purpose,
153
+ url: loggedUrl,
154
+ message: err && err.message ? err.message : String(err)
155
+ });
98
156
  });
99
157
 
100
- return { client, url };
158
+ return { client, url, loggedUrl };
101
159
  }
102
160
 
103
161
  /**
104
162
  * Create and connect a Redis client with a hard timeout (useful for tests/tools).
105
163
  *
164
+ * Every way the connect can fail — node-redis rejecting on its own, the hard
165
+ * race expiring — leaves through ONE message in the `[Context] Problem - Fix`
166
+ * shape, with the original error as `cause`.
167
+ *
106
168
  * @param {Object} options - Same as createRedisClient plus:
107
- * @param {number} [options.timeoutMs=15000] - Max time to wait for connect()
169
+ * @param {number} [options.timeoutMs=15000] - Max time to wait for connect().
170
+ * node-redis' own attempt is given `CONNECT_ATTEMPT_SHARE` of it, so the
171
+ * attempt ends — and releases its socket — inside this ceiling.
108
172
  * @returns {Promise<import('redis').RedisClientType>}
109
173
  */
110
174
  async function connectRedis(options = {}) {
111
- const { timeoutMs = 15000, logger = console } = options;
112
- const { client, url } = createRedisClient(options);
175
+ const { timeoutMs = 15000 } = options;
176
+ // Validated here as well as in createRedisClient, so the message names the
177
+ // entry point the caller actually used.
178
+ const logger = assertLogger(
179
+ 'connectRedis',
180
+ options.logger,
181
+ 'the established connection and the Redis error events are recorded'
182
+ );
183
+ const hasDeadline = Boolean(timeoutMs) && timeoutMs > 0;
184
+ // node-redis' own attempt ends a fifth of the budget BEFORE our hard race, so
185
+ // the socket it opened is destroyed by the library that owns it — our
186
+ // `disconnect()` below cannot reach a socket that is not assigned yet.
187
+ // @see CONNECT_ATTEMPT_SHARE
188
+ const connectAttemptTimeoutMs = hasDeadline
189
+ ? Math.max(1, Math.floor(timeoutMs * CONNECT_ATTEMPT_SHARE))
190
+ : undefined;
191
+ // `url` carries the credential and is never rendered anywhere below;
192
+ // `loggedUrl` is the endpoint form and is what every message here uses.
193
+ const { client, loggedUrl } = createRedisClient({ ...options, connectAttemptTimeoutMs });
113
194
 
114
- if (!timeoutMs || timeoutMs <= 0) {
195
+ if (!hasDeadline) {
115
196
  await client.connect();
116
- if (logger && typeof logger.info === 'function') {
117
- logger.info('[Redis] Connected', { url });
118
- }
197
+ logger.info('[Redis] Connected', { url: loggedUrl });
119
198
  return client;
120
199
  }
121
200
 
122
201
  const connectPromise = client.connect();
123
- const timeoutPromise = new Promise((_, reject) => {
124
- setTimeout(
125
- () => reject(new Error(`Redis connection timeout after ${timeoutMs}ms to ${url}`)),
202
+ let raceLost = false;
203
+ let timeoutHandle;
204
+ // The race arm carries no message of its own — it only says which arm won.
205
+ // Every failure then leaves this function through the single throw below, so
206
+ // the contract message has one owner and node-redis' bare 'Connection
207
+ // timeout' never reaches a caller.
208
+ const hardDeadline = new Promise((resolve) => {
209
+ timeoutHandle = setTimeout(
210
+ () => {
211
+ raceLost = true;
212
+ resolve();
213
+ },
126
214
  timeoutMs
127
215
  );
128
216
  });
129
217
 
130
- await Promise.race([connectPromise, timeoutPromise]);
218
+ let cause;
219
+ try {
220
+ await Promise.race([connectPromise, hardDeadline]);
221
+ } catch (err) {
222
+ cause = err;
223
+ } finally {
224
+ // Promise.race settles on the first arm; the loser keeps running. Without
225
+ // this the process held the timer for the full timeoutMs after a connection
226
+ // that had already succeeded — long enough to keep the event loop alive.
227
+ clearTimeout(timeoutHandle);
228
+ }
229
+
230
+ if (!raceLost && cause === undefined) {
231
+ logger.info('[Redis] Connected', { url: loggedUrl });
232
+ return client;
233
+ }
131
234
 
132
- if (logger && typeof logger.info === 'function') {
133
- logger.info('[Redis] Connected', { url });
235
+ // The connect arm keeps running after it loses the race — with node-redis'
236
+ // default reconnect strategy it keeps retrying — and the caller never
237
+ // receives the client on this path, so this function is the only place that
238
+ // can release the socket it opened. `isOpen` is false when connect() failed
239
+ // on its own (node-redis closed it already) and disconnecting then throws.
240
+ if (client.isOpen) {
241
+ try {
242
+ await client.disconnect();
243
+ } catch (disconnectErr) {
244
+ // Reported, never swallowed — and never in place of the original
245
+ // failure, which is what the caller asked about.
246
+ logger.warn('[Redis] Releasing the failed connection failed', {
247
+ url: loggedUrl,
248
+ message: disconnectErr && disconnectErr.message
249
+ ? disconnectErr.message
250
+ : String(disconnectErr)
251
+ });
252
+ }
134
253
  }
135
254
 
136
- return client;
255
+ // One shape for every way a connect can fail, with the original kept as
256
+ // `cause` rather than interpolated — interpolating copies the text and throws
257
+ // the stack away. The verdict is the one fact that differs: a deadline (ours,
258
+ // or the one we gave node-redis) is not the same event as a refused
259
+ // connection, and a message calling it one would be untrue.
260
+ const verdict = raceLost || isConnectDeadline(cause)
261
+ ? `timeout after ${timeoutMs}ms`
262
+ : 'failed';
263
+ // node-redis builds its messages from host:port and never renders the URL it
264
+ // was handed, so the credential cannot arrive through here.
265
+ const reason = cause === undefined
266
+ ? 'the client never reported ready'
267
+ : (cause.message ? cause.message : String(cause));
268
+
269
+ throw new Error(
270
+ `[service-common][redisClient] Redis connection ${verdict} to ${loggedUrl} - ${reason}. `
271
+ + 'Fix: check that Redis is running at that URL, or raise timeoutMs if the instance '
272
+ + 'is merely slow to accept.',
273
+ { cause }
274
+ );
137
275
  }
138
276
 
139
277
  module.exports = {
@@ -29,7 +29,8 @@
29
29
  * @see api/docs/standards/redis-key-contract.md — registry namespace
30
30
  */
31
31
 
32
- const { createClient } = require('redis');
32
+ const { assertLogger } = require('@onlineapps/logger-contract');
33
+ const { connectRedis } = require('./redisClient');
33
34
  const DEFAULTS = require('./defaults');
34
35
 
35
36
  const CONTEXT = '[service-common][registryReader]';
@@ -45,18 +46,6 @@ function requireKeyPrefix(keyPrefix) {
45
46
  return keyPrefix;
46
47
  }
47
48
 
48
- function requireLogger(logger) {
49
- const missing = ['info', 'warn', 'error'].filter(
50
- (method) => !logger || typeof logger[method] !== 'function'
51
- );
52
- if (missing.length > 0) {
53
- throw new Error(
54
- `${CONTEXT} Missing dependency - logger is required and must implement ${missing.join(', ')} `
55
- + '(no console fallback). Fix: inject the service logger.'
56
- );
57
- }
58
- return logger;
59
- }
60
49
 
61
50
  function requireTtl(value, name, fallbackDefault) {
62
51
  if (value === undefined) return fallbackDefault;
@@ -112,7 +101,8 @@ function parseOrThrow(raw, key, what) {
112
101
  * @param {string} options.keyPrefix Registry key namespace (REQUIRED, no default)
113
102
  * @param {string} [options.redisUrl] Redis connection URL
114
103
  * @param {object} [options.client] Connected node-redis v4 client
115
- * @param {object} options.logger Logger implementing info/warn/error (REQUIRED)
104
+ * @param {object} options.logger Logger implementing info/warn/error/debug (REQUIRED,
105
+ * `@onlineapps/logger-contract`; no console fallback)
116
106
  * @param {number} [options.ttlMs] TTL of a cached spec (default: defaults.registryReaderTtlMs)
117
107
  * @param {number} [options.negativeTtlMs] TTL of a cached "not registered" (default: defaults.registryReaderNegativeTtlMs)
118
108
  * @returns {object} reader
@@ -149,7 +139,16 @@ function createRegistryReader(options) {
149
139
  requireClientContract(client);
150
140
  }
151
141
 
152
- const log = requireLogger(logger);
142
+ // The platform's logger contract has ONE owner (`@onlineapps/logger-contract`,
143
+ // confirmation `connector-logger-contract` 001/004): four methods, validated
144
+ // up front. The reader used to re-state a three-method variant of it here —
145
+ // the last local copy in this package — which `connectRedis` could not honour,
146
+ // because the client it creates logs through the same object.
147
+ const log = assertLogger(
148
+ 'createRegistryReader',
149
+ logger,
150
+ 'the registry reads and the Redis error events of the connection it owns reach the structured log'
151
+ );
153
152
  const positiveTtl = requireTtl(ttlMs, 'ttlMs', DEFAULTS.registryReaderTtlMs);
154
153
  const negativeTtl = requireTtl(negativeTtlMs, 'negativeTtlMs', DEFAULTS.registryReaderNegativeTtlMs);
155
154
  const connectTimeout = requireTtl(
@@ -180,34 +179,19 @@ function createRegistryReader(options) {
180
179
  if (connected) return;
181
180
 
182
181
  if (ownsClient) {
183
- const created = createClient({ url: redisUrl, socket: { connectTimeout } });
184
- created.on('error', (err) => {
185
- log.error(`${CONTEXT} Redis client error: ${err.message}`);
182
+ // Opening a Redis connection with a ceiling is ONE mechanism and it lives
183
+ // in ./redisClient.js: the ceiling the caller asked for, the attempt
184
+ // deadline derived from it (so node-redis destroys its own socket inside
185
+ // that ceiling), the unified 'error' logging with the userinfo stripped,
186
+ // and one contract message carrying the original error as `cause`. The
187
+ // reader states the budget and nothing else.
188
+ // @see ./redisClient.js connectRedis
189
+ redis = await connectRedis({
190
+ purpose: 'registryReader',
191
+ redisUrl,
192
+ logger: log,
193
+ timeoutMs: connectTimeout
186
194
  });
187
-
188
- // node-redis keeps retrying a refused connection forever, so the initial
189
- // connect gets its own hard ceiling — startup must fail, not hang.
190
- let timeoutHandle = null;
191
- const deadline = new Promise((_resolve, reject) => {
192
- timeoutHandle = setTimeout(() => reject(new Error(
193
- `${CONTEXT} Redis connection timeout - no connection to ${redisUrl} within ${connectTimeout}ms. `
194
- + 'Fix: check the Redis address and that the instance is running.'
195
- )), connectTimeout);
196
- });
197
-
198
- try {
199
- await Promise.race([created.connect(), deadline]);
200
- } catch (err) {
201
- try {
202
- await created.disconnect();
203
- } catch (_ignored) {
204
- // The client never opened; nothing to release.
205
- }
206
- throw err;
207
- } finally {
208
- clearTimeout(timeoutHandle);
209
- }
210
- redis = created;
211
195
  }
212
196
 
213
197
  connected = true;