@onlineapps/conn-infra-secrets 2.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,115 @@ 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
+
21
+ ## [3.0.0] — 2026-09-16
22
+
23
+ ### Changed — BREAKING
24
+
25
+ - **The Redis credential and topology come from `redisUrl`, and from nothing else.** The constructor reads `username` (Redis 6 ACL), `password`, host, port and the database index off the URL — `redis://[user:password@]host:port[/db]`, `rediss://` for TLS — decoding percent-escapes exactly once. An absent userinfo means no AUTH at all: `username`/`password` are then not passed to ioredis, never as `''`, which would be sent as `AUTH ""`.
26
+ Measured reason (INFRA, dev, 2026-09-15 21:20Z): the constructor took `hostname`/`port` only and expected the password beside the URL, so a URL carrying userinfo lost it — ioredis connected without AUTH, the gateway went into a crash loop (RestartCount 51) and the dispatcher and ingest failed on their first secret read. `REDIS_URL` is the platform's one rail for this fact (INFRA-DOCS 2026-09-14; `service-common` 3.0.2 hands the whole URL to node-redis, `mq-client-core` 3.2.0 reads the credential off the URL), so a value beside it was a second rail — `.claude/rules/change-discipline.md` § One rail per concern.
27
+ - **A `redisUrl` that is not a `redis:`/`rediss:` URL is refused at construction**, as is one without a host or without a port. The silent branch that treated such a value as the hostname and defaulted the port to `6379` is gone: it hardcoded topology (principle 2) behind a fallback (principle 3), and it is what let a misspelt value reach a live boot. No message echoes the URL or the refused value — both carry the password.
28
+
29
+ ### Removed
30
+
31
+ - **`config.password`** — refused by name, not ignored (a retired option that is silently dropped leaves the caller believing a password reached the server). Four questions, `.claude/rules/change-discipline.md` § Removing: (1) it came to exist because the constructor read only host and port off the URL, so a Redis with `requirepass` needed the password from somewhere — `ServiceWrapper` began setting it on 2026-09-11; (2) no part of the concept carried it: the platform decision is that `REDIS_URL` carries endpoint AND credential, and it never sanctioned a second input; (3) after this change nothing reads it — the two callers that set it (`api/shared/connector/service-wrapper/src/ServiceWrapper.js`, `api_biz/ingest/scripts/lib/build-operator-context.js`) each re-derive the value from the very same URL, a copy of the parse rather than a second source; (4) the replacement is more conceptual — one rail, one parse, and a URL with userinfo can no longer lose its password without a word.
32
+ - **`config.db`** — refused by name; the database index is the URL's path. Four questions: (1) it came to exist as `config.db || 0`, so a caller could select a database; (2) nothing in the concept carried it — no platform document declares a non-default database for the SecretBox projection; (3) zero callers pass a value: only `ServiceWrapper` forwards `secretsConfig.db`, which no service config declares (`api_biz/ingest/config/service/config.json` has `"secrets": { "enabled": true }`); (4) the path is more conceptual — the database belongs to the address, and `|| 0` additionally swallowed a malformed declaration into a 0 nobody asked for. With no path declared the connector passes no `db` at all and ioredis selects its own default, database 0 — named here rather than re-implemented.
33
+
34
+ ### Added
35
+
36
+ - `tests/integration/redisAuthFromUrl.integration.test.js` — a throwaway `redis:7-alpine` with `requirepass` on an ephemeral loopback port (the shared dev cache runs without one and cannot answer this question): the happy path reads a real sealed secret back through an authenticated connection, the failure path shows the same URL without the password refused with `NOAUTH`, and the control case proves the server still required AUTH after the green run.
37
+
38
+ ### Changed — a failed `connect()` carries the server's verdict (d.566)
39
+
40
+ `connect()` handed the caller ioredis's end-of-socket sentence — `Connection is closed.`,
41
+ no context, no `cause`, no verdict — while the server's own answer reached `_lastError`
42
+ and the log alone. Measured against a `redis:7-alpine --requirepass` on 2026-09-16: a URL
43
+ without the password (`NOAUTH Authentication required.`), a URL with the wrong password
44
+ (`WRONGPASS invalid username-password pair or user is disabled.`) and a port with no
45
+ listener (`connect ECONNREFUSED …`) produced byte-identical caller errors. That is why dev
46
+ incident B88 (gateway crash loop, RestartCount 51) could not be read from the error at all.
47
+
48
+ The caller is now told which of two things happened:
49
+
50
+ - the server **ANSWERED and refused** — `NOAUTH`, `WRONGPASS`, `NOPERM`. The message
51
+ quotes the server's sentence and names the credential in `REDIS_URL` as the fix, and the
52
+ server's error travels as `cause`.
53
+ - the server **never answered** — the message says exactly that, quotes what the client
54
+ rejected with, and adds that a refused credential answers NOAUTH/WRONGPASS/NOPERM and is
55
+ reported as such, so this is not one. That error travels as `cause`.
56
+
57
+ It is the rule `@onlineapps/mq-client-core` d.295 states for a broker refusal (403/530/406),
58
+ on the same reasoning: an ANSWERED refusal is permanent, because the next attempt asks the
59
+ same question and gets the same answer.
60
+
61
+ So a refusal also **ends the attempts**, and a failed attempt takes its client with it.
62
+ Measured on all three failure paths: ioredis was left in status `reconnecting` after
63
+ `connect()` rejected, so a credential the server had already refused was asked again on a
64
+ loop, and the connector — which never sets `connected` on a later reconnect — would never
65
+ have used that socket anyway. A refusal on the `error` channel now disconnects the client
66
+ at once, and every failed `connect()` drops it.
67
+
68
+ Only the endpoint (`host:port`) is named in these messages, never the URL: this package
69
+ keeps the parsed fields and drops the value precisely because the value carries the
70
+ password.
71
+
72
+ ### Added (d.566)
73
+
74
+ - `tests/unit/connectVerdict.test.js` — both sentences asserted whole, the `cause` as a
75
+ value, no part of the credential in either, the refusal that ends the attempts, and two
76
+ controls (an accepted `connect()` still resolves `true` and keeps its client; a
77
+ transport error after the connection is open is NOT treated as a refusal).
78
+ - `tests/integration/redisAuthFromUrl.integration.test.js` grew the two failure paths this
79
+ change is about — a wrong password named `WRONGPASS` with no part of it echoed, and a
80
+ port with no listener reported as "no verdict" with `cause.code` `ECONNREFUSED` — and
81
+ its NOAUTH path now asserts the verdict the caller gets instead of `Connection is
82
+ closed.`. The error-message census moved 31 → 33.
83
+
84
+ ### Fixed — a store that comes back is read again (d.573)
85
+
86
+ `connected` was written once, when `connect()` resolved, and never read off the client
87
+ again. The connector therefore had no idea what the client was doing: a Redis that went
88
+ away mid-run left the flag `true`, so `get()` handed the read to a client with no socket
89
+ instead of naming the missing store — and nothing could ever put the flag back, because
90
+ the reconnection ioredis performs on its own was not watched. One outage decided the fate
91
+ of every secret read for the rest of the process's life.
92
+
93
+ `connected` is now the client's READY state — socket open, AUTH and SELECT done — one rail
94
+ with `@onlineapps/conn-base-cache` d.572: `ready` sets it, `close` and `end` take it down,
95
+ the next `ready` puts it back. Every handler acts only for the client this connector still
96
+ holds, so the verdict of d.566 stays exactly as terminal as it was: a client the server
97
+ refused is discarded, and nothing it emits afterwards can put the connector back in
98
+ service behind the caller's back.
99
+
100
+ ### Tests (d.573)
101
+
102
+ Unit 177 → 182, integration 26 → 28. New `tests/unit/connectionLifecycle.test.js` asserts
103
+ the state machine as values: the read that reaches the store answers `SECRET_NOT_FOUND`,
104
+ the one after a `close` answers `SECRET_STORE_UNAVAILABLE` and never touches the client,
105
+ and the flag through an outage reads `[true, false, true]` (it read `[true, true, true]`
106
+ before the fix), with `end` as a second way down and a refusal that survives a `ready`
107
+ from its discarded client as the control. New
108
+ `tests/integration/reconnect.integration.test.js` asks a real server: its own
109
+ `redis:7` container on a fixed loopback port, a value sealed and read back, `docker stop`
110
+ → `SECRET_STORE_UNAVAILABLE`, `docker start` → the same read succeeds, each step waited
111
+ for on the client's own `close`/`ready` event rather than on a guessed delay, with a
112
+ `WRONGPASS` refusal as the control that stays terminal.
113
+
114
+ ## [2.0.0] — 2026-09-14
115
+
7
116
  ### Changed — BREAKING
8
117
 
9
118
  - **`SECRETS_MASTER_KEYS` replaces `SECRETS_MASTER_KEY`**: the package reads an ordered LIST of master keys (`<key_id>:<base64 key>[,…]`). The first key seals every new write; every key in the list opens what it sealed, selected by the blob's `key_id`. A blob naming a key that is not in the list is refused with `SECRET_KEY_UNKNOWN` — there is no "try every key" pass. `SecretsConnector` takes `masterKeys` instead of `masterKeyBase64` (owner decision 2026-09-14, `docs/governance/confirmations/secretbox-master-keys.md` 001).
package/README.md CHANGED
@@ -96,6 +96,30 @@ await secrets.connect();
96
96
  Handlers only ever call `ctx.secrets.get(ref)`; the ContextBuilder facade injects
97
97
  the invocation scope.
98
98
 
99
+ **The Redis credential lives in the URL and nowhere else.** `redisUrl` carries the
100
+ endpoint, the optional Redis 6 ACL user, the password and the database index —
101
+ `redis://[user:password@]host:port[/db]`, `rediss://` for TLS — exactly as
102
+ `service-common` and `mq-client-core` read it. Percent-encode a password that
103
+ contains `@`, `:` or `/`. The options `password` and `db` beside the URL are
104
+ **refused by name**: they were a second rail for a fact the URL already carries,
105
+ and while they existed a URL with userinfo lost its password silently (INFRA, dev,
106
+ 2026-09-15: ioredis connected without AUTH, gateway crash loop, RestartCount 51).
107
+ A value that is not a `redis:`/`rediss:` URL, or one without a host or a port, is
108
+ refused at construction — this package declares no topology default, not even 6379.
109
+
110
+ **A failed `connect()` says what the server answered.** A server that ANSWERED and
111
+ refused the credential — `NOAUTH`, `WRONGPASS`, `NOPERM` — is permanent: the message
112
+ quotes the server's sentence, names `REDIS_URL` as the fix, carries the original error
113
+ as `cause`, and the attempts END there rather than looping on a question already
114
+ answered. A server that never answered is reported as exactly that and may be retried.
115
+ Only `host:port` appears in these messages; the URL carries the password.
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
+
99
123
  ## Public API
100
124
  ```js
101
125
  const SecretsConnector = require('@onlineapps/conn-infra-secrets'); // default = the connector
@@ -257,8 +281,22 @@ failed.
257
281
  controls that no value and no key material reaches a result or a message.
258
282
  - `npm run test:integration` — real Redis. The re-encryption run uses the live
259
283
  projection as its store and reads the rewritten rows back through
260
- `SecretsConnector.get()` with the key list that remains. Needs `REDIS_HOST` +
261
- `REDIS_PORT` (dev stack: `REDIS_HOST=127.0.0.1 REDIS_PORT=33030`, container
262
- `api_node_cache`). **NOT RUN here:** the authoritative store — the rows live in
263
- `oagen_<service>.secret` (MySQL) and this package has no database dependency by
264
- 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": "2.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
@@ -39,6 +39,70 @@ const { normalizeWorkspaceId } = require('./workspaceId');
39
39
  // Meta owns the projection; its StateConnector prefixes keys with `state:meta:`.
40
40
  const DEFAULT_KEY_PREFIX = 'state:meta:secret:';
41
41
 
42
+ /**
43
+ * The sentences with which a Redis server ANSWERS a connection attempt and
44
+ * REFUSES it — an answer, not a silence:
45
+ *
46
+ * NOAUTH the server requires AUTH and this connection sent none
47
+ * WRONGPASS the credential the URL carries is not the server's
48
+ * NOPERM the ACL user the URL names may not run the command
49
+ *
50
+ * The distinction is the one `@onlineapps/mq-client-core` draws for a broker
51
+ * (`_isBrokerRefusal()`, reply codes 403/530/406, d.295): an ANSWERED refusal is
52
+ * permanent — the next attempt asks the same question and gets the same answer,
53
+ * so the attempts end and the caller is told what the server said. Anything else
54
+ * is transient by design: nobody answered, and giving up forever on a server that
55
+ * may come back is a different defect.
56
+ *
57
+ * The list holds only sentences a live Redis 6/7 actually sends on this path:
58
+ * NOAUTH and WRONGPASS were measured against `redis:7-alpine --requirepass`
59
+ * (2026-09-16), NOPERM is the same class for the ACL user the URL may name. Redis
60
+ * 5's `ERR invalid password` is deliberately absent — the platform runs Redis 7,
61
+ * and a ban nothing can trigger is silence pretending to be coverage
62
+ * (`automation-gates.md` §5). `ERR Client sent AUTH, but no password is set` is
63
+ * absent for the same reason: ioredis warns on it and does not fail the
64
+ * connection (`ioredis/built/redis/event_handler.js`, `connectHandler`).
65
+ */
66
+ const SERVER_REFUSAL_PREFIXES = ['NOAUTH', 'WRONGPASS', 'NOPERM'];
67
+
68
+ /**
69
+ * Did the server answer and refuse?
70
+ * @param {Error|null|undefined} err
71
+ * @returns {boolean}
72
+ */
73
+ function isServerRefusal(err) {
74
+ if (!err || typeof err.message !== 'string') return false;
75
+ return SERVER_REFUSAL_PREFIXES.some((prefix) => err.message.startsWith(prefix));
76
+ }
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
+
42
106
  class SecretResolutionError extends Error {
43
107
  constructor(code, message, options) {
44
108
  super(message, options);
@@ -73,7 +137,9 @@ function workspaceSegment(workspaceId) {
73
137
  return String(ws);
74
138
  }
75
139
 
76
- 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);
77
143
  if (tenantId === undefined || tenantId === null) {
78
144
  throw new SecretResolutionError(
79
145
  'SECRET_SCOPE_MISSING',
@@ -81,42 +147,273 @@ function projectionKey(tenantId, workspaceId, ref, keyPrefix = DEFAULT_KEY_PREFI
81
147
  + 'belongs to. Fix: pass scope.tenant_id from the operation context.'
82
148
  );
83
149
  }
84
- return `${keyPrefix}${tenantId}:${workspaceSegment(workspaceId)}:${ref}`;
150
+ return `${prefix}${tenantId}:${workspaceSegment(workspaceId)}:${ref}`;
151
+ }
152
+
153
+ // `REDIS_URL` is the platform's single rail for the Redis endpoint AND its
154
+ // credential (`service-common` 3.0.2 hands the whole URL to node-redis;
155
+ // `mq-client-core` 3.2.0 reads the credential off the URL). Anything else this
156
+ // package might accept beside it would be a second rail for the same fact.
157
+ const REDIS_URL_PROTOCOLS = new Set(['redis:', 'rediss:']);
158
+
159
+ /**
160
+ * Options beside `redisUrl` that used to carry a fact the URL already carries.
161
+ * They are REFUSED by name rather than ignored: an ignored option looks accepted
162
+ * and leaves the caller believing a password reached the server
163
+ * (`.claude/rules/change-discipline.md` § One rail per concern; principle 11,
164
+ * clean break — no shim that keeps reading them).
165
+ */
166
+ const RETIRED_OPTIONS = {
167
+ password: 'the Redis password comes from the userinfo of redisUrl (redis://user:password@host:port)',
168
+ db: 'the Redis database index comes from the path of redisUrl (redis://host:port/3)'
169
+ };
170
+
171
+ function refuseRetiredOptions(config) {
172
+ for (const [name, replacement] of Object.entries(RETIRED_OPTIONS)) {
173
+ if (config[name] === undefined) continue;
174
+ throw new Error(
175
+ `[conn-infra-secrets] Option "${name}" is not accepted - ${replacement}. `
176
+ + `Fix: drop "${name}" from the SecretsConnector config and put the value in REDIS_URL `
177
+ + '(api/config/env-active/shared.env); a value beside the URL is a second rail for a fact '
178
+ + 'the URL already carries. The refused value is not echoed here.'
179
+ );
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The database index the URL declares, or `null` when it declares none.
185
+ *
186
+ * No path (`redis://h:6379`) and an empty path (`redis://h:6379/`) declare
187
+ * nothing, and the connector then passes no `db` at all — ioredis selects its own
188
+ * default, database 0. That default belongs to ioredis and is named here rather
189
+ * than re-implemented as `config.db || 0`, which would also swallow a malformed
190
+ * declaration into a 0 nobody asked for (principle 3, No Fallbacks).
191
+ */
192
+ function databaseFromPath(pathname) {
193
+ // `URL.pathname` is always a string ('' when the URL declares no path).
194
+ const declared = pathname.replace(/^\//, '');
195
+ if (declared === '') return null;
196
+ if (!/^\d+$/.test(declared)) {
197
+ throw new Error(
198
+ `[conn-infra-secrets] redisUrl declares database "${declared}" - Expected a non-negative `
199
+ + 'integer as the URL path, e.g. redis://host:6379/3. Fix: correct the path of REDIS_URL in '
200
+ + 'shared.env, or drop it to use the default database.'
201
+ );
202
+ }
203
+ return Number.parseInt(declared, 10);
204
+ }
205
+
206
+ /**
207
+ * Build the ioredis options from the URL — endpoint, ACL user and password alike.
208
+ *
209
+ * Measured reason this function exists (INFRA, dev, 2026-09-15 21:20Z): the
210
+ * constructor used to take `hostname`/`port` only and expect the password in a
211
+ * separate `config.password`, so a URL carrying userinfo lost it and ioredis
212
+ * connected without AUTH — gateway crash loop (RestartCount 51), dispatcher and
213
+ * ingest failing on the first secret read.
214
+ *
215
+ * Nothing here echoes the URL or any part of its userinfo: these messages reach
216
+ * logs, and the value carries the password.
217
+ */
218
+ function redisOptionsFromUrl(redisUrl) {
219
+ let parsed;
220
+ try {
221
+ parsed = new URL(redisUrl);
222
+ } catch (err) {
223
+ throw new Error(
224
+ '[conn-infra-secrets] redisUrl is not a URL - Expected redis://[user:password@]host:port[/db] '
225
+ + '(or rediss:// for TLS), the one rail that carries both the endpoint and the credential. '
226
+ + 'Fix: set REDIS_URL in api/config/env-active/shared.env to the full URL; a bare host:port '
227
+ + 'is not accepted. The value is not echoed here because it carries the password.',
228
+ { cause: err }
229
+ );
230
+ }
231
+ if (!REDIS_URL_PROTOCOLS.has(parsed.protocol)) {
232
+ throw new Error(
233
+ `[conn-infra-secrets] redisUrl has scheme "${parsed.protocol}" - Expected redis: or rediss:. `
234
+ + 'Fix: set REDIS_URL in api/config/env-active/shared.env to redis://host:port (rediss:// for TLS).'
235
+ );
236
+ }
237
+ if (!parsed.hostname) {
238
+ throw new Error(
239
+ '[conn-infra-secrets] redisUrl declares no host - Expected redis://host:port. '
240
+ + 'Fix: set REDIS_URL in api/config/env-active/shared.env to the full endpoint.'
241
+ );
242
+ }
243
+ if (!parsed.port) {
244
+ throw new Error(
245
+ '[conn-infra-secrets] redisUrl declares no port - Expected redis://host:port; the port is '
246
+ + 'topology and this package declares no default for it. Fix: set REDIS_URL in '
247
+ + 'api/config/env-active/shared.env to the full endpoint, e.g. redis://api_node_cache:6379.'
248
+ );
249
+ }
250
+
251
+ const options = {
252
+ host: parsed.hostname,
253
+ port: Number.parseInt(parsed.port, 10),
254
+ lazyConnect: true
255
+ };
256
+ // Redis 6 ACL: the URL may name a user as well as a password. An absent one is
257
+ // ABSENT — never `''`, which ioredis would send as `AUTH ""`.
258
+ if (parsed.username) options.username = decodeURIComponent(parsed.username);
259
+ if (parsed.password) options.password = decodeURIComponent(parsed.password);
260
+ const db = databaseFromPath(parsed.pathname);
261
+ if (db !== null) options.db = db;
262
+ return options;
263
+ }
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;
85
284
  }
86
285
 
87
286
  class SecretsConnector {
88
287
  /**
89
288
  * @param {Object} config
90
- * @param {string} config.redisUrl - redis://host:port (required)
289
+ * @param {string} config.redisUrl - `redis://[user:password@]host:port[/db]` or `rediss://…`
290
+ * (required). The URL is the ONLY rail: endpoint, ACL user, password and
291
+ * database index all come off it. `config.password` and `config.db` are
292
+ * refused by name.
91
293
  * @param {string} config.masterKeys - the declared list, `<key_id>:<base64 32-byte key>[,…]`
92
294
  * (required; the value of SECRETS_MASTER_KEYS, first entry = the active key)
93
- * @param {string} [config.password] - Redis password
94
- * @param {number} [config.db=0] - Redis database index
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.
95
297
  */
96
298
  constructor(config = {}) {
97
299
  if (!config.redisUrl) {
98
300
  throw new Error('[conn-infra-secrets] redisUrl is required - Fix: pass REDIS_URL.');
99
301
  }
302
+ refuseRetiredOptions(config);
100
303
  this._keyring = loadMasterKeys(config.masterKeys);
101
- this._keyPrefix = config.keyPrefix || DEFAULT_KEY_PREFIX;
102
-
103
- let host = config.redisUrl;
104
- let port = 6379;
105
- if (config.redisUrl.startsWith('redis://')) {
106
- const parsed = new URL(config.redisUrl);
107
- host = parsed.hostname;
108
- port = parseInt(parsed.port, 10) || 6379;
109
- }
110
- this._redisOptions = { host, port, password: config.password, db: config.db || 0, lazyConnect: true };
304
+ this._keyPrefix = resolveKeyPrefix(config.keyPrefix);
305
+
306
+ this._redisOptions = redisOptionsFromUrl(config.redisUrl);
307
+ // What a message of this package may say about the store: host and port,
308
+ // never the URL. The connector keeps the parsed fields and drops the value
309
+ // precisely because the value carries the password, so there is no URL here
310
+ // to redact — and nothing to redact it with (this package declares one
311
+ // dependency, ioredis).
312
+ this._endpoint = `${this._redisOptions.host}:${this._redisOptions.port}`;
111
313
  this.client = null;
314
+ // Whether the CLIENT is ready — socket open, AUTH and SELECT done. It is not
315
+ // a latch set once by connect(): it follows the client through an outage and
316
+ // back (d.573), one rail with `@onlineapps/conn-base-cache` d.572.
112
317
  this.connected = false;
318
+ this._lastError = null;
113
319
  }
114
320
 
321
+ /**
322
+ * Open the connection, or say what the server answered.
323
+ *
324
+ * Until d.566 this method handed the caller ioredis's end-of-socket sentence —
325
+ * `Connection is closed.`, no `cause`, no verdict — while the server's actual
326
+ * answer reached `_lastError` and nothing else. That is how dev incident B88
327
+ * (gateway crash loop, RestartCount 51) could not be read from the error at
328
+ * all: the log said the socket closed, and the `NOAUTH` that closed it was a
329
+ * field nobody printed.
330
+ *
331
+ * Two outcomes now, told apart by `isServerRefusal()` and said in the caller's
332
+ * error: the server ANSWERED and refused (permanent — the attempts end here),
333
+ * or it never answered (transient — the caller may try again). Either way the
334
+ * original error travels as `cause`, so the stack survives the sentence.
335
+ *
336
+ * A failed attempt also takes its client with it. ioredis keeps retrying in the
337
+ * background after `connect()` rejects — measured 2026-09-16: status
338
+ * `reconnecting` on all three failure paths — so a connector that gives up
339
+ * without disconnecting leaves a socket loop nobody owns, and this one never
340
+ * reports `connected` again for it: the lifecycle handlers below act only for
341
+ * the client this connector still holds, so nothing that client emits after it
342
+ * has been discarded can put the connector back in service.
343
+ *
344
+ * What `connected` means afterwards is the client's own READY state, watched
345
+ * rather than assumed (d.573): a Redis that goes away mid-run takes the flag
346
+ * down, so `get()` names the missing store instead of walking into a client
347
+ * with no socket, and the reconnection ioredis performs on its own puts the
348
+ * flag back. Before d.573 the flag was written once and never read off the
349
+ * client again, so an outage decided the fate of every secret read that
350
+ * followed it.
351
+ *
352
+ * @returns {Promise<true>}
353
+ * @throws {Error} naming the server's verdict, with the original error as `cause`
354
+ */
115
355
  async connect() {
116
356
  if (this.connected) return true;
117
- this.client = new Redis(this._redisOptions);
118
- this.client.on('error', (err) => { /* surfaced on get() */ this._lastError = err; });
119
- await this.client.connect();
357
+ this._lastError = null;
358
+ const client = new Redis(this._redisOptions);
359
+ this.client = client;
360
+
361
+ // `ready`, not `connect`: ioredis emits `connect` when the socket opens,
362
+ // which is before AUTH and before SELECT, so it would report a usable store
363
+ // for a connection the server is about to refuse (the same rule as
364
+ // `@onlineapps/conn-base-cache` d.572). Every handler checks that this is
365
+ // still THE client of this connector: a discarded one keeps emitting for a
366
+ // while, and its events belong to nobody.
367
+ const mine = () => this.client === client;
368
+ client.on('ready', () => { if (mine()) this.connected = true; });
369
+ // `close` is the socket going away with a reconnection still to come; `end`
370
+ // is the client giving up for good. Both mean the store is not readable now.
371
+ client.on('close', () => { if (mine()) this.connected = false; });
372
+ client.on('end', () => { if (mine()) this.connected = false; });
373
+
374
+ client.on('error', (err) => {
375
+ // Surfaced on get(), and read below as the verdict of a failed connect().
376
+ this._lastError = err;
377
+ // A refusal the server has already spoken does not change on the next
378
+ // attempt: end the reconnection rather than ask again in a loop.
379
+ if (isServerRefusal(err)) client.disconnect();
380
+ });
381
+
382
+ try {
383
+ await client.connect();
384
+ } catch (rejection) {
385
+ // The verdict comes from whichever error carries it: ioredis delivers the
386
+ // server's sentence on the `error` channel and rejects connect() with the
387
+ // state of the socket, but a rejection that carries the verdict itself is
388
+ // read the same way.
389
+ const refusal = [this._lastError, rejection].find(isServerRefusal) || null;
390
+ const reported = refusal || this._lastError || rejection;
391
+
392
+ client.disconnect();
393
+ this.client = null;
394
+ this.connected = false;
395
+
396
+ if (refusal) {
397
+ throw new Error(
398
+ `[conn-infra-secrets] Failed to connect to Redis at ${this._endpoint} - the server `
399
+ + `answered and refused it with "${refusal.message}". Fix: correct the credential in `
400
+ + 'REDIS_URL (api/config/env-active/shared.env) — the ACL user too, if the URL names '
401
+ + 'one; a server that answers this way refuses every retry, so the connector stopped '
402
+ + 'instead of looping. Only the endpoint is named here; the URL carries the password.',
403
+ { cause: refusal }
404
+ );
405
+ }
406
+ throw new Error(
407
+ `[conn-infra-secrets] Failed to connect to Redis at ${this._endpoint} - the server sent `
408
+ + `no verdict; the client rejected with "${describeTransportFailure(reported)}". Fix: check that Redis is `
409
+ + 'listening at that endpoint and that the host and port in REDIS_URL '
410
+ + '(api/config/env-active/shared.env) are the right ones. A refused credential answers '
411
+ + 'NOAUTH, WRONGPASS or NOPERM and is reported as such, so this is not one. Only the '
412
+ + 'endpoint is named here; the URL carries the password.',
413
+ { cause: reported }
414
+ );
415
+ }
416
+
120
417
  this.connected = true;
121
418
  return true;
122
419
  }