@onlineapps/conn-infra-secrets 3.0.0 → 3.1.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 CHANGED
@@ -4,6 +4,20 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.1.0] — 2026-10-03
8
+
9
+ ### Changed — an unusable `keyPrefix` is refused instead of replaced, and a `connect()` rejected with an `AggregateError` says why (d.1136, d.1116)
10
+
11
+ - **The exported `projectionKey()` refuses an unusable `keyPrefix` too.** Its parameter default `keyPrefix = DEFAULT_KEY_PREFIX` let `''` through as a key with no prefix and `null` as the literal prefix `"null"`. It now goes through the constructor's rail: left out (`undefined`) = `DEFAULT_KEY_PREFIX`, a non-empty string = used as is, anything else refused with the same message (d.1136). The dead `pathname || ''` in the `redisUrl` database parse is gone — `URL.pathname` is always a string.
12
+ - **An unusable `keyPrefix` is refused instead of replaced by the default.** `config.keyPrefix || DEFAULT_KEY_PREFIX` swapped an explicit `''` (and `null`) for the default without a word, and let a truthy non-string — `42`, an object — through as a prefix (principle 3, No Fallbacks; INFRA-DOCS W46, d.1136). Left out (`undefined`) still means `DEFAULT_KEY_PREFIX`, the prefix meta writes — the default is the concept, not a guess: no caller passes one (`ServiceWrapper`, `api_gateway`, `api_delivery_dispatcher`). A given non-empty string is used as is; anything else throws `[conn-infra-secrets] Invalid config - "keyPrefix" must be a non-empty string, got …`. Unit cases for `''`, `null`, a number and an object plus two controls; integration cases against the live store (refusal of `''`, and a given prefix reading only its own keys).
13
+ - **A `connect()` rejected with an `AggregateError` now says why.** A host name with more than one address (`localhost` → `::1`, `127.0.0.1`) makes Node try each of them — the locked ioredis 5.11.1 defaults to `family: 0` — and reject with an `AggregateError` whose own `message` is `""`; the caller read `the client rejected with ""`, a refusal with no reason in it (d.1116, measured on this package in d.1123). The message now carries each attempt's reason from `errors[]` — `AggregateError with no message of its own; its 2 attempts failed with: connect ECONNREFUSED ::1:1; connect ECONNREFUSED 127.0.0.1:1` — in the shape of `describeTransportFailure()` in `@onlineapps/service-common`. Unit case plus an integration case against `redis://localhost:1`.
14
+
15
+ ### Tests — the integration tier: one deadline for every `docker` call, no password in its errors, Redis awaited where the connector dials it (d.1140)
16
+
17
+ - **Every `docker` call of the integration tier runs under one deadline** (d.1140, 2/2). `reconnect.integration.test.js` ran `execFileSync('docker', …)` with no timeout — a stuck daemon hung the run with nothing named, and Jest's per-test timeout cannot fire while `execFileSync` blocks — while `redisAuthFromUrl` kept the timeout in a second copy of the same helper. Both now go through `tests/dockerCli.js`: `docker(args)` under the named `DOCKER_TIMEOUT_MS`, and a call that outlives it ends with `[conn-infra-secrets/integration] "docker <subcommand>" did not finish within … ms - … Fix: …`. The integration contract pins the rail per suite.
18
+ - **A failed `docker` call of the integration tier no longer prints the password its command line carries** (d.1140, 3/3). The `child_process` error quotes the whole line (`Command failed: …`) and keeps it in `spawnargs`/`cmd`; re-thrown as it was, or attached as `cause`, it carried `--requirepass <password>` / `redis-cli -a <password>` into the run's output — measured on the timeout, the non-zero-exit and the did-not-start path alike. Every failure of `tests/dockerCli.js` is now its own error naming `docker <subcommand>` and how it ended (`did not finish within … ms`, `exited with status N`, `did not run (code …)`), with `cause` = `{ code, status, signal }` and nothing else; stdout and stderr are left out. Unit cases on real processes assert that no rendering of the error — message, stack, JSON, `util.inspect` to depth 5 — contains the argument that stands for the password.
19
+ - **The integration suites that run a Redis of their own wait for it where the connector dials it** (d.1140). `reconnect.integration.test.js` asked `docker exec … redis-cli PING` — the server on the container's own loopback — while the connector connects to the port the daemon publishes, and under load the two disagreed: lead d.1123 (load 31–37) saw no `ready` for 60 s after an in-container PONG and `Connection is closed.` in place of `WRONGPASS` in the CONTROL; worker d.1123 (load 26) saw `ECONNREFUSED` there. Both suites that own a Redis (`reconnect`, `redisAuthFromUrl`, which kept a second copy of the wait loop) now wait through one rail, `tests/redisPing.js` — PING at the published endpoint with a deadline named `REDIS_PING_DEADLINE_MS` — after `docker run`, after every `docker start`, and before each `reconnect` test's first connection. No assertion changed. The unit tier's integration contract pins the rail per suite, with a control that administering the server (`CONFIG SET requirepass`) stays a `docker exec`.
20
+
7
21
  ## [3.0.0] — 2026-09-16
8
22
 
9
23
  ### Changed — BREAKING
package/README.md CHANGED
@@ -114,6 +114,12 @@ as `cause`, and the attempts END there rather than looping on a question already
114
114
  answered. A server that never answered is reported as exactly that and may be retried.
115
115
  Only `host:port` appears in these messages; the URL carries the password.
116
116
 
117
+ **`keyPrefix` is optional, and only left out means the default.** Leave it out and
118
+ the connector reads the prefix meta writes — `DEFAULT_KEY_PREFIX` in `src/index.js`
119
+ is its one owner; no caller in the platform passes one. A given value is used as
120
+ is. A given value that is not a non-empty string (`''`, `null`, a number, an
121
+ object) is refused at construction, never replaced by the default.
122
+
117
123
  ## Public API
118
124
  ```js
119
125
  const SecretsConnector = require('@onlineapps/conn-infra-secrets'); // default = the connector
@@ -275,8 +281,22 @@ failed.
275
281
  controls that no value and no key material reaches a result or a message.
276
282
  - `npm run test:integration` — real Redis. The re-encryption run uses the live
277
283
  projection as its store and reads the rewritten rows back through
278
- `SecretsConnector.get()` with the key list that remains. Needs `REDIS_HOST` +
279
- `REDIS_PORT` (dev stack: `REDIS_HOST=127.0.0.1 REDIS_PORT=33030`, container
280
- `api_node_cache`). **NOT RUN here:** the authoritative store — the rows live in
281
- `oagen_<service>.secret` (MySQL) and this package has no database dependency by
282
- design, so that adapter and its integration test belong to the owning service.
284
+ `SecretsConnector.get()` with the key list that remains. Needs `REDIS_URL` and
285
+ nothing else (`tests/config.js` refuses the run before the first describe tree
286
+ when it is missing) — the one value that carries the endpoint AND the
287
+ credential. Two suites need no variable of their own beyond it: they start a
288
+ throwaway Redis whose password they set themselves
289
+ (`redisAuthFromUrl`, `reconnect`). Derive the value, never write it out:
290
+
291
+ ```bash
292
+ # from shared/connector/conn-infra-secrets/
293
+ REDIS_URL="$(node -p "const f=require('fs'),u=new URL(/^REDIS_URL=(.*)/m.exec(f.readFileSync('../../../config/env-active/shared.env','utf8'))[1]);u.host='127.0.0.1:'+/^REDIS_PORT=(.*)/m.exec(f.readFileSync('../../../infra/.env','utf8'))[1];u.toString()")" \
294
+ npm run test:integration
295
+ ```
296
+
297
+ `api/config/env-active/shared.env` owns the in-network URL (`api_node_cache`),
298
+ `api/infra/.env` the published port; neither is in git, and a copy here would be
299
+ a second source for a secret. **NOT RUN here:** the authoritative store — the
300
+ rows live in `oagen_<service>.secret` (MySQL) and this package has no database
301
+ dependency by design, so that adapter and its integration test belong to the
302
+ owning service.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-infra-secrets",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Secret resolution connector for ctx.secrets.get(ref) — reads the SecretBox Redis projection and decrypts in-process (AES-256-GCM)",
5
5
  "oa": {
6
6
  "category": "connector"
package/src/index.js CHANGED
@@ -75,6 +75,34 @@ function isServerRefusal(err) {
75
75
  return SERVER_REFUSAL_PREFIXES.some((prefix) => err.message.startsWith(prefix));
76
76
  }
77
77
 
78
+ /**
79
+ * The reason a failed connect attempt carries, in words — including one whose own
80
+ * message is empty.
81
+ *
82
+ * A host name with more than one address (A + AAAA) makes Node >= 20 try each of them
83
+ * (`autoSelectFamily`) and reject with an `AggregateError` whose own `message` is `""`;
84
+ * the reasons are in `errors[]`, one per attempt (measured d.1116: `localhost:1` ->
85
+ * `errors: [connect ECONNREFUSED ::1:1, connect ECONNREFUSED 127.0.0.1:1]`). Embedding
86
+ * `message` alone then printed `rejected with ""` — a refusal with no reason in it.
87
+ * The shape is `describeTransportFailure()` of `@onlineapps/service-common`
88
+ * (`src/redisClient.js`) and `describeCause()` of `@onlineapps/content-resolver`
89
+ * (`src/errors.js`).
90
+ *
91
+ * @param {*} cause - What the connect attempt rejected with.
92
+ * @returns {string}
93
+ */
94
+ function describeTransportFailure(cause) {
95
+ if (cause && cause.message) {
96
+ return cause.message;
97
+ }
98
+ if (cause && Array.isArray(cause.errors) && cause.errors.length > 0) {
99
+ const reasons = cause.errors.map((attempt) => attempt.message).join('; ');
100
+ return `${cause.name} with no message of its own; its ${cause.errors.length} attempts `
101
+ + `failed with: ${reasons}`;
102
+ }
103
+ return String(cause);
104
+ }
105
+
78
106
  class SecretResolutionError extends Error {
79
107
  constructor(code, message, options) {
80
108
  super(message, options);
@@ -109,7 +137,9 @@ function workspaceSegment(workspaceId) {
109
137
  return String(ws);
110
138
  }
111
139
 
112
- function projectionKey(tenantId, workspaceId, ref, keyPrefix = DEFAULT_KEY_PREFIX) {
140
+ function projectionKey(tenantId, workspaceId, ref, keyPrefix) {
141
+ // One rail with the constructor: left out = DEFAULT_KEY_PREFIX, unusable = refused.
142
+ const prefix = resolveKeyPrefix(keyPrefix);
113
143
  if (tenantId === undefined || tenantId === null) {
114
144
  throw new SecretResolutionError(
115
145
  'SECRET_SCOPE_MISSING',
@@ -117,7 +147,7 @@ function projectionKey(tenantId, workspaceId, ref, keyPrefix = DEFAULT_KEY_PREFI
117
147
  + 'belongs to. Fix: pass scope.tenant_id from the operation context.'
118
148
  );
119
149
  }
120
- return `${keyPrefix}${tenantId}:${workspaceSegment(workspaceId)}:${ref}`;
150
+ return `${prefix}${tenantId}:${workspaceSegment(workspaceId)}:${ref}`;
121
151
  }
122
152
 
123
153
  // `REDIS_URL` is the platform's single rail for the Redis endpoint AND its
@@ -160,7 +190,8 @@ function refuseRetiredOptions(config) {
160
190
  * declaration into a 0 nobody asked for (principle 3, No Fallbacks).
161
191
  */
162
192
  function databaseFromPath(pathname) {
163
- const declared = String(pathname || '').replace(/^\//, '');
193
+ // `URL.pathname` is always a string ('' when the URL declares no path).
194
+ const declared = pathname.replace(/^\//, '');
164
195
  if (declared === '') return null;
165
196
  if (!/^\d+$/.test(declared)) {
166
197
  throw new Error(
@@ -231,6 +262,27 @@ function redisOptionsFromUrl(redisUrl) {
231
262
  return options;
232
263
  }
233
264
 
265
+ /**
266
+ * The projection key prefix: left out means DEFAULT_KEY_PREFIX, the one meta
267
+ * writes; a given value is used as is. A given value that cannot be a prefix is
268
+ * refused rather than replaced — `config.keyPrefix || DEFAULT_KEY_PREFIX` swapped
269
+ * an explicit `''` for the default without a word, and let `42` or an object
270
+ * through as a prefix (principle 3, No Fallbacks; INFRA-DOCS W46). The exported
271
+ * projectionKey() goes through the same rail — its parameter default let '' and
272
+ * null through as well.
273
+ */
274
+ function resolveKeyPrefix(keyPrefix) {
275
+ if (keyPrefix === undefined) return DEFAULT_KEY_PREFIX;
276
+ if (typeof keyPrefix !== 'string' || keyPrefix === '') {
277
+ const shown = typeof keyPrefix === 'string' ? '""' : (keyPrefix === null ? 'null' : typeof keyPrefix);
278
+ throw new Error(
279
+ `[conn-infra-secrets] Invalid config - "keyPrefix" must be a non-empty string, got ${shown}. `
280
+ + 'Fix: pass config.keyPrefix or leave it out (default: see DEFAULT_KEY_PREFIX in src/index.js).'
281
+ );
282
+ }
283
+ return keyPrefix;
284
+ }
285
+
234
286
  class SecretsConnector {
235
287
  /**
236
288
  * @param {Object} config
@@ -240,7 +292,8 @@ class SecretsConnector {
240
292
  * refused by name.
241
293
  * @param {string} config.masterKeys - the declared list, `<key_id>:<base64 32-byte key>[,…]`
242
294
  * (required; the value of SECRETS_MASTER_KEYS, first entry = the active key)
243
- * @param {string} [config.keyPrefix] - projection key prefix (defaults to the one meta writes)
295
+ * @param {string} [config.keyPrefix] - projection key prefix; left out (`undefined`) = DEFAULT_KEY_PREFIX,
296
+ * the one meta writes. A given value must be a non-empty string, otherwise it is refused.
244
297
  */
245
298
  constructor(config = {}) {
246
299
  if (!config.redisUrl) {
@@ -248,7 +301,7 @@ class SecretsConnector {
248
301
  }
249
302
  refuseRetiredOptions(config);
250
303
  this._keyring = loadMasterKeys(config.masterKeys);
251
- this._keyPrefix = config.keyPrefix || DEFAULT_KEY_PREFIX;
304
+ this._keyPrefix = resolveKeyPrefix(config.keyPrefix);
252
305
 
253
306
  this._redisOptions = redisOptionsFromUrl(config.redisUrl);
254
307
  // What a message of this package may say about the store: host and port,
@@ -352,7 +405,7 @@ class SecretsConnector {
352
405
  }
353
406
  throw new Error(
354
407
  `[conn-infra-secrets] Failed to connect to Redis at ${this._endpoint} - the server sent `
355
- + `no verdict; the client rejected with "${reported.message}". Fix: check that Redis is `
408
+ + `no verdict; the client rejected with "${describeTransportFailure(reported)}". Fix: check that Redis is `
356
409
  + 'listening at that endpoint and that the host and port in REDIS_URL '
357
410
  + '(api/config/env-active/shared.env) are the right ones. A refused credential answers '
358
411
  + 'NOAUTH, WRONGPASS or NOPERM and is reported as such, so this is not one. Only the '